Vortos
Persistence

Write Repositories

Implement DbalMapper, annotate your repository with

Write Repositories

Write repositories use composition, not inheritance. You implement a DbalMapper class that describes your table shape, annotate your repository with #[UsesDbalMapper], and the framework injects a configured DbalStore at compile time. All persistence mechanics live in the store — your repository stays focused on queries.

Required Table Structure

Every table using DBAL persistence must have these two columns:

CREATE TABLE users (
    id           VARCHAR(36)  PRIMARY KEY,  -- UUID string (UuidV7)
    lock_version INTEGER      NOT NULL DEFAULT 0,
    -- your domain columns below
    email        VARCHAR(255) NOT NULL,
    name         VARCHAR(255) NOT NULL
);

The id column stores your aggregate's UUID as a string. The lock_version column is managed by the store for optimistic concurrency control — you never touch it directly.

Step 1 — Implement DbalMapper

DbalMapper is a four-method interface that describes how your aggregate maps to a database table. Create one class per aggregate:

src/User/Infrastructure/Database/UserMapper.php
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,
            'name'          => Types::STRING,
            'email'         => Types::STRING,
            'password_hash' => Types::STRING,
        ];
    }

    public function toRow(AggregateRoot $aggregate): array
    {
        /** @var User $aggregate */
        return [
            'id'            => (string) $aggregate->getId(),
            'name'          => $aggregate->getName(),
            'email'         => (string) $aggregate->getEmail(),
            'password_hash' => $aggregate->getPasswordHash(),
        ];
    }

    public function fromRow(array $row): AggregateRoot
    {
        return User::reconstruct(
            id:           UserId::fromString($row['id']),
            email:        new Email($row['email']),
            name:         $row['name'],
            passwordHash: $row['password_hash'],
        );
    }
}

The mapper has no framework base class — it is a pure data-mapping class. The DbalStore that uses it is created and injected by the compiler pass.

Step 2 — Annotate Your Repository

Apply #[UsesDbalMapper] and declare a DbalStore constructor argument. The compiler pass creates and injects the store automatically:

src/User/Infrastructure/Database/UserWriteRepository.php
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);
    }
}

No extends. No abstract methods. The repository implements your pure domain interface directly.

Step 3 — Register

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

DbalRepositoryCompilerPass detects #[UsesDbalMapper] at compile time, creates a dedicated DbalStore wired to UserMapper and the shared Connection, and injects it as the $store argument. No manual argument configuration needed.

The reconstruct() Pattern

fromRow() calls a named constructor on the aggregate to restore its persisted version:

src/User/Domain/User.php
final class User extends AggregateRoot
{
    private function __construct(
        private readonly UserId $id,
        private readonly Email $email,
        private string $name,
        private string $passwordHash,
    ) {}

    public static function register(string $name, string $email, string $plainPassword): self
    {
        $id   = UserId::generate();
        $user = new self($id, new Email($email), $name, password_hash($plainPassword, PASSWORD_BCRYPT));
        $user->recordEvent(new UserRegisteredEvent((string) $id, $email, $name));
        return $user;
    }

    // For loading from database — DbalStore restores lock_version automatically via hydrate()
    public static function reconstruct(
        UserId $id,
        Email $email,
        string $name,
        string $passwordHash,
    ): self {
        return new self($id, $email, $name, $passwordHash);
    }

    public function getId(): UserId { return $this->id; }
    public function getEmail(): Email { return $this->email; }
    public function getName(): string { return $this->name; }
    public function getPasswordHash(): string { return $this->passwordHash; }
}

Built-In Operations

save(AggregateRoot $aggregate): void

$this->store->save($user);
  • New aggregate (isNew() returns true) → INSERT
  • Existing aggregate → UPDATE WHERE lock_version = :expectedVersion
  • Throws OptimisticLockException if another process modified the aggregate since it was loaded.

After a successful save, incrementVersion() is called on the aggregate automatically.

find(AggregateId $id): ?AggregateRoot

/** @var User|null */
return $this->store->find($id);

Returns null if not found — never throws for missing records.

delete(AggregateRoot $aggregate): void

$this->store->delete($user);

Applies optimistic locking on delete. Throws OptimisticLockException if the aggregate was modified since it was loaded.

Custom Queries

Use $this->store->createQueryBuilder() for any query beyond find(). Access the table name and row mapping via $this->store->mapper():

public function findByEmail(Email $email): ?User
{
    $row = $this->store->createQueryBuilder()
        ->select('*')
        ->from($this->store->mapper()->tableName())
        ->where('email = :email')
        ->setParameter('email', (string) $email)
        ->executeQuery()
        ->fetchAssociative();

    /** @var User|null */
    return $row !== false ? $this->store->hydrate($row) : null;
}

public function findAllActive(): array
{
    $rows = $this->store->createQueryBuilder()
        ->select('*')
        ->from($this->store->mapper()->tableName())
        ->where('status = :status')
        ->setParameter('status', 'active')
        ->orderBy('created_at', 'DESC')
        ->executeQuery()
        ->fetchAllAssociative();

    return array_map(fn(array $row) => $this->store->hydrate($row), $rows);
}

Never Use Raw SQL Strings

Always use createQueryBuilder() — never raw SQL strings. QueryBuilder handles parameter escaping and is database-portable. Raw strings bypass DBAL's type binding and are vulnerable to SQL injection.

Batch Operations

batchInsert(array $aggregates): void

Single INSERT statement for multiple new aggregates. All must be new (isNew() returns true).

$users = array_map(
    fn($row) => User::register($row['name'], $row['email'], $row['password']),
    $csvRows,
);
$this->store->batchInsert($users);

batchUpdate(array $aggregates): void

Updates multiple aggregates. DbalStore calls save() per aggregate (applies optimistic locking per row). PostgresStore overrides this with a single UPDATE FROM VALUES query — see below.

batchUpsert(array $aggregates): void

PostgreSQL INSERT ... ON CONFLICT (id) DO UPDATE SET. Does not apply optimistic locking.

batchUpsert Does Not Lock

batchUpsert() silently overwrites any version in the database. Use only for read model projections, idempotent bulk imports, or test seeding — never for commands where concurrent modification must be detected.

batchDelete(array $ids): void

$this->store->batchDelete([
    UserId::fromString('user-1'),
    UserId::fromString('user-2'),
]);

Single DELETE WHERE id IN (...). Does not apply optimistic locking.

PostgresStore — Optimised Batch Updates

For PostgreSQL applications with large batch updates, specify PostgresStore in the attribute. It overrides batchUpdate() with PostgreSQL's UPDATE FROM VALUES — a single query regardless of batch size:

src/User/Infrastructure/Database/UserWriteRepository.php
use Vortos\PersistenceDbal\Attribute\UsesDbalMapper;
use Vortos\PersistenceDbal\Write\PostgresStore;

#[UsesDbalMapper(UserMapper::class, storeClass: PostgresStore::class)]
final class UserWriteRepository implements UserRepositoryInterface
{
    public function __construct(private readonly PostgresStore $store) {}

    // All operations identical — batchUpdate() now uses a single query
}
MethodDbalStorePostgresStore
batchUpdate()One UPDATE per aggregateOne UPDATE FROM VALUES for all
Optimistic locking in batchPer-row, throws on conflictSilent skip on version mismatch
Database portabilityAll DBAL driversPostgreSQL only

Use PostgresStore when batch sizes exceed ~50 aggregates and you are on PostgreSQL. For smaller batches the difference is negligible.

On this page