Vortos
Make

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

aggregateAggregate class name. PascalCase. E.g., User, Order.
--context / -cBounded context folder name. Defaults to the aggregate name if omitted.

Example

php bin/console vortos:make:write-repository User --context=User

Creates

src/User/Domain/User/Repository/UserRepositoryInterface.php  (skipped if already exists)
src/User/Infrastructure/Database/UserMapper.php
src/User/Infrastructure/Database/UserWriteRepository.php

UserMapper.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_versionDbalStore 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

aggregateAggregate class name. PascalCase. E.g., User, Order.
--context / -cBounded context folder name. Defaults to the aggregate name if omitted.

Example

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

Creates

src/User/Application/ReadModel/UserReadModel.php
src/User/Infrastructure/Persistence/Mongo/UserReadRepository.php

UserReadRepository.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 controller

Use $this->store->upsert() in your projection handler (never insert) and findById() / findByCriteria() in your query handler.

On this page