Persistence Generators
Generate PostgreSQL write repositories and MongoDB read repositories with the correct mapper, store wiring, and attributes.
Persistence Generators
These commands generate the infrastructure layer repositories — the concrete classes that read and write your aggregates to the database.
vortos:make:write-repository
Generates a PostgreSQL write repository for an aggregate. Also generates the repository interface if it doesn't already exist.
php bin/console vortos:make:write-repository <aggregate> [--context=<context>]Arguments & Options
aggregate | Aggregate class name. PascalCase. E.g., User, Order. |
--context / -c | Bounded context folder name. Defaults to the aggregate name if omitted. |
Example
php bin/console vortos:make:write-repository User --context=UserCreates
src/User/Domain/User/Repository/UserRepositoryInterface.php (skipped if already exists)
src/User/Infrastructure/Database/UserMapper.php
src/User/Infrastructure/Database/UserWriteRepository.phpUserMapper.php
<?php
declare(strict_types=1);
namespace App\User\Infrastructure\Database;
use Doctrine\DBAL\Types\Types;
use Vortos\Domain\Aggregate\AggregateRoot;
use Vortos\PersistenceDbal\Write\DbalMapper;
final class UserMapper implements DbalMapper
{
public function tableName(): string
{
return 'users';
}
public function columnMap(): array
{
return [
'id' => Types::STRING,
// add your domain columns here — lock_version is handled automatically
];
}
public function toRow(AggregateRoot $aggregate): array
{
/** @var User $aggregate */
return [
'id' => (string) $aggregate->getId(),
// map aggregate state to DB columns here — do NOT include lock_version
];
}
public function fromRow(array $row): AggregateRoot
{
return User::reconstruct(
UserId::fromString($row['id']),
// do NOT pass lock_version — DbalStore restores it automatically via hydrate()
);
}
}UserWriteRepository.php
<?php
declare(strict_types=1);
namespace App\User\Infrastructure\Database;
use App\User\Domain\User\User;
use App\User\Domain\User\UserId;
use App\User\Domain\User\Repository\UserRepositoryInterface;
use Vortos\PersistenceDbal\Attribute\UsesDbalMapper;
use Vortos\PersistenceDbal\Write\DbalStore;
#[UsesDbalMapper(UserMapper::class)]
final class UserWriteRepository implements UserRepositoryInterface
{
public function __construct(private readonly DbalStore $store) {}
public function save(User $user): void
{
$this->store->save($user);
}
public function delete(User $user): void
{
$this->store->delete($user);
}
public function findById(UserId $id): ?User
{
/** @var User|null */
return $this->store->find($id);
}
// Add custom query methods here. Example:
//
// public function findByEmail(string $email): ?User
// {
// $row = $this->store->createQueryBuilder()
// ->select('*')
// ->from($this->store->mapper()->tableName())
// ->where('email = :email')
// ->setParameter('email', $email)
// ->executeQuery()
// ->fetchAssociative();
//
// /** @var User|null */
// /** @var User|null */
// return $row !== false ? $this->store->hydrate($row) : null;
// }
}Fill in columnMap, toRow, and fromRow in UserMapper with your domain columns. Do not include lock_version — DbalStore injects it on INSERT, applies it in the WHERE clause on UPDATE/DELETE, and restores it on the aggregate after find() and hydrate() automatically.
ORM Variant
If vortos/vortos-persistence-orm is installed, the generator uses the ORM stub instead — #[UsesOrmEntity(User::class)] on the repository, no mapper file. The repository interface is unchanged.
Never use batchUpdate for conflict-safe operations
PostgresStore::batchUpdate() silently skips rows with version conflicts rather than throwing OptimisticLockException. It is suitable only for last-write-wins bulk operations, not for conflict-safe aggregate updates. Switch to PostgresStore via the storeClass argument on #[UsesDbalMapper] only when you need the single-query batch update path.
vortos:make:read-repository
Generates a MongoDB read repository — the read side of CQRS that serves query results.
php bin/console vortos:make:read-repository <aggregate> [--context=<context>]Arguments & Options
aggregate | Aggregate class name. PascalCase. E.g., User, Order. |
--context / -c | Bounded context folder name. Defaults to the aggregate name if omitted. |
Example
php bin/console vortos:make:read-repository User --context=UserCreates
src/User/Application/ReadModel/UserReadModel.php
src/User/Infrastructure/Persistence/Mongo/UserReadRepository.phpUserReadRepository.php
<?php
declare(strict_types=1);
namespace App\User\Infrastructure\Persistence\Mongo;
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: ['_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;
}
// Add custom query methods here. Example:
//
// public function findByStatus(string $status, int $limit = 50): array
// {
// $docs = $this->store->findByCriteria(['status' => $status], limit: $limit);
// return array_map(fn(array $doc) => $this->fromDocument($doc), $docs);
// }
//
// For paginated results:
//
// public function findPage(array $criteria, int $limit, ?string $cursor = null): PageResult
// {
// $raw = $this->store->findPage($criteria, $limit, $cursor);
// return new PageResult(
// items: array_map(fn(array $doc) => $this->fromDocument($doc), $raw->items),
// nextCursor: $raw->nextCursor,
// hasMore: $raw->hasMore,
// );
// }
private function fromDocument(array $doc): UserReadModel
{
return new UserReadModel(
id: $doc['_id'],
// map document fields to read model here
);
}
}Fill in fromDocument to transform MongoDB documents into your read model shape. Add #[MongoIndex] attributes alongside the queries that use those indexes.
Store _id as string UUID
MongoDB's _id field must always be stored as a string UUID, never as ObjectId. Cross-service event correlation depends on UUID identity being consistent between the write and read sides.
Projection handlers write, read repositories read
The typical flow for a read model:
Event arrives from Kafka
└── ProjectionHandler::__invoke()
└── UserReadRepository::upsert($store->upsert('user-123', [...]))
Query arrives via HTTP
└── GetUserByIdHandler::__invoke()
└── UserReadRepository::findById($query->userId)
└── Returns typed UserReadModel to controllerUse $this->store->upsert() in your projection handler (never insert) and findById() / findByCriteria() in your query handler.