Vortos
Persistence

Read Repositories

Implement typed read repositories backed by MongoDB or PostgreSQL, with cursor pagination and bulk write operations.

Read Repositories

The read side in Vortos is intentionally separate from the write side. Write repositories own domain aggregates and transactional consistency. Read repositories own query models — flat, denormalised structures optimised for how the UI asks for data.

In DDD + CQRS, the read model is an application-layer concern, not a domain concern. Read models live in Application/ReadModel/, repositories live in Infrastructure/Persistence/.

Vortos provides two patterns depending on your read store:

PatternRead storeWhen to use
#[MongoCollection] + MongoStoreMongoDBSelf-contained document projections, high read throughput, frequent schema changes
DbalReadRepository (abstract)PostgreSQLComplex joins or CTEs, single-database setups, full-text search with pg_trgm

MongoDB Read Repositories

Why MongoDB for reads?

The read side exists to serve query handlers as efficiently as possible without the constraints of a normalised write schema. MongoDB is a natural fit when your read model is a single self-contained document — the document is already the query answer.

Step 1 — Create the read model

The read model is a pure typed DTO. No framework imports, no database annotations:

src/User/Application/ReadModel/UserReadModel.php
final readonly class UserReadModel
{
    public function __construct(
        public string $id,
        public string $email,
        public string $name,
        public string $status,
        public ?string $createdAt,
    ) {}
}

Step 2 — Create the repository

Declare the collection name, indexes, and a MongoStore constructor argument. The compiler pass injects the store automatically:

src/User/Infrastructure/Persistence/Mongo/UserReadRepository.php
use App\User\Application\ReadModel\UserReadModel;
use App\User\Application\UserReadRepositoryInterface;
use Vortos\PersistenceMongo\Read\MongoStore;
use Vortos\PersistenceMongo\Schema\Attribute\MongoCollection;
use Vortos\PersistenceMongo\Schema\Attribute\MongoIndex;

#[MongoCollection('users')]
#[MongoIndex(key: ['email' => 1], unique: true)]
#[MongoIndex(key: ['createdAt' => -1, '_id' => -1])]
final class UserReadRepository implements UserReadRepositoryInterface
{
    public function __construct(private readonly MongoStore $store) {}

    public function findById(string $id): ?UserReadModel
    {
        $doc = $this->store->findById($id);
        return $doc !== null ? $this->fromDocument($doc) : null;
    }

    private function fromDocument(array $doc): UserReadModel
    {
        return new UserReadModel(
            id:        $doc['_id'],
            email:     $doc['email'] ?? '',
            name:      $doc['name'] ?? '',
            status:    $doc['status'] ?? 'active',
            createdAt: $doc['createdAt'] ?? null,
        );
    }
}

MongoStore returns raw ?array from findById() and findByCriteria(). The fromDocument() method maps to your typed read model — it lives privately in the repository where it belongs.

Step 3 — Register

config/services.php
$services->set(UserReadRepository::class);

MongoReadRepositoryAutowirePass detects every class with #[MongoCollection] and creates a dedicated MongoStore service configured with the correct collection name, database, and client. The store is injected as the $store argument. No manual argument wiring needed.

Step 4 — Generate with make command

php bin/console vortos:make:read-repository User

Generates both files at once, then you fill in the fields.

Required: Store _id as string UUID

Always store _id as a string UUID (UuidV7). Mixing with MongoDB ObjectId causes silent type mismatch bugs when querying by ID.

// CORRECT
['_id' => 'user-019d4784-...', 'email' => 'alice@example.com']

// WRONG
['_id' => new ObjectId(), 'email' => 'alice@example.com']

Built-In Store Operations

All methods on MongoStore return raw arrays — your fromDocument() maps them to typed read models.

store->findById(string $id): ?array

$doc = $this->store->findById('user-019d...');
// null if not found

store->findByCriteria(array $criteria, array $sort, int $limit, ?string $cursor): array

$docs = $this->store->findByCriteria(
    criteria: ['status' => 'active'],
    sort: ['createdAt' => 'desc'],
    limit: 20,
);
$users = array_map(fn($doc) => $this->fromDocument($doc), $docs);

store->findPage(...): PageResult

See the Pagination page.

store->upsert(string $id, array $document): void

$this->store->upsert('user-123', [
    '_id'       => 'user-123',
    'email'     => 'alice@example.com',
    'name'      => 'Alice',
    'status'    => 'active',
    'createdAt' => (new \DateTimeImmutable())->format('c'),
]);

store->bulkUpsert(array $documents): void / store->bulkDelete(array $ids): void

$this->store->bulkUpsert([
    ['_id' => 'user-1', 'email' => 'alice@example.com'],
    ['_id' => 'user-2', 'email' => 'bob@example.com'],
]);

$this->store->bulkDelete(['user-1', 'user-2']);

Custom Queries

Access the raw MongoDB\Collection via $this->store->collection() for anything beyond findByCriteria():

public function findByEmailDomain(string $domain): array
{
    $cursor = $this->store->collection()->find([
        'email' => ['$regex' => '@' . preg_quote($domain) . '$'],
    ]);

    return array_map(
        fn($doc) => $this->fromDocument((array) $doc),
        iterator_to_array($cursor),
    );
}

public function countByRole(): array
{
    return iterator_to_array($this->store->collection()->aggregate([
        ['$group' => ['_id' => '$role', 'count' => ['$sum' => 1]]],
        ['$sort' => ['count' => -1]],
    ]));
}

PostgreSQL Read Repositories

When to use PostgreSQL for reads

  • Your team operates a single PostgreSQL instance and does not want to run MongoDB
  • Your read model spans multiple aggregates and needs a JOIN or CTE
  • You need full-text search backed by pg_trgm or tsvector

Step 1 — Create the read model

Same as MongoDB — a pure DTO in Application/ReadModel/. The read model does not know what database stores it:

src/Billing/Application/ReadModel/InvoiceReadModel.php
final readonly class InvoiceReadModel
{
    public function __construct(
        public string $id,
        public string $customerId,
        public int $totalCents,
        public string $status,
        public string $issuedAt,
    ) {}
}

Step 2 — Create the repository

src/Billing/Infrastructure/Persistence/Dbal/InvoiceReadRepository.php
use App\Billing\Application\ReadModel\InvoiceReadModel;
use Vortos\PersistenceDbal\Read\DbalReadRepository;

/**
 * @extends DbalReadRepository<InvoiceReadModel>
 */
final class InvoiceReadRepository extends DbalReadRepository
{
    protected function tableName(): string
    {
        return 'invoice_read_models';
    }

    protected function fromRow(array $row): InvoiceReadModel
    {
        return new InvoiceReadModel(
            id:         $row['id'],
            customerId: $row['customer_id'],
            totalCents: (int) $row['total_cents'],
            status:     $row['status'],
            issuedAt:   $row['issued_at'],
        );
    }
}

Step 3 — Indexes live in SQL migrations

Unlike MongoDB, there is no attribute system for DBAL indexes. Declare the table and indexes in a normal SQL migration:

CREATE TABLE invoice_read_models (
    id          VARCHAR(36)  NOT NULL PRIMARY KEY,
    customer_id VARCHAR(36)  NOT NULL,
    total_cents BIGINT       NOT NULL DEFAULT 0,
    status      VARCHAR(32)  NOT NULL,
    issued_at   TIMESTAMPTZ  NOT NULL
);

CREATE INDEX idx_invoice_rm_customer ON invoice_read_models (customer_id);
CREATE INDEX idx_invoice_rm_status   ON invoice_read_models (status, issued_at DESC);
php bin/console vortos:migrate --force

Built-In Operations

upsert(string $id, array $data): void

PostgreSQL INSERT ... ON CONFLICT (id) DO UPDATE SET. Pass data without id — it is the first argument:

$invoiceReadRepository->upsert($event->invoiceId, [
    'customer_id' => $event->customerId,
    'total_cents' => $event->totalCents,
    'status'      => 'open',
    'issued_at'   => $event->occurredAt()->format('c'),
]);

delete(string $id): void

$invoiceReadRepository->delete($event->invoiceId);

All other operations (findById, findByCriteria, findPage, countByCriteria) work identically to the MongoDB version — returning typed read models.

Custom Queries

Use the protected query() method — returns a QueryBuilder pre-configured with SELECT * FROM {table}:

public function findWithCustomerName(string $customerId): array
{
    return $this->query()
        ->select('i.*, c.name AS customer_name')
        ->join('i', 'customer_read_models', 'c', 'i.customer_id = c.id')
        ->where('i.customer_id = :cid')
        ->setParameter('cid', $customerId)
        ->fetchAllAssociative();
}

Projections in Event Handlers

Both repository types are populated from projection handlers that react to domain events:

use Vortos\Cqrs\Attribute\AsProjectionHandler;
use Vortos\Cqrs\Projection\ProjectionHandlerInterface;

#[AsProjectionHandler(consumer: 'user.events', handlerId: 'user.registered.project')]
final class UserProjection implements ProjectionHandlerInterface
{
    public function __construct(
        private readonly UserReadRepository $readRepository,
    ) {}

    public function __invoke(UserRegisteredEvent $event): void
    {
        $this->readRepository->upsert($event->aggregateId(), [
            '_id'       => $event->aggregateId(),   // MongoDB: include _id in document
            'email'     => $event->email,
            'name'      => $event->name,
            'status'    => 'active',
            'createdAt' => $event->occurredAt()->format(\DateTimeInterface::ATOM),
        ]);
    }
}

For DbalReadRepository, omit _id from the data — pass it as the first argument instead:

$this->readRepository->upsert($event->aggregateId(), [
    'customer_id' => $event->customerId,
    'total_cents' => $event->totalCents,
    'status'      => 'open',
    'issued_at'   => $event->occurredAt()->format('c'),
]);

Query Handlers

The query handler receives the repository and calls it. Return type is fully typed:

use App\User\Application\ReadModel\UserReadModel;

#[AsQueryHandler]
final class GetUserByIdQueryHandler
{
    public function __construct(
        private readonly UserReadRepositoryInterface $repository,
    ) {}

    public function __invoke(GetUserByIdQuery $query): Result
    {
        $user = $this->repository->findById($query->userId);

        if ($user === null) {
            return Result::fail(new UserNotFoundError(...));
        }

        return Result::ok($user);
    }
}

UserReadModel has public readonly properties — json_encode() serializes them automatically, so JsonResponse($user) in the controller works without any manual conversion.

On this page