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:
| Pattern | Read store | When to use |
|---|---|---|
#[MongoCollection] + MongoStore | MongoDB | Self-contained document projections, high read throughput, frequent schema changes |
DbalReadRepository (abstract) | PostgreSQL | Complex 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:
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:
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
$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 UserGenerates 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 foundstore->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_trgmortsvector
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:
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
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 --forceBuilt-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.