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:
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:
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
$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:
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()returnstrue) →INSERT - Existing aggregate →
UPDATE WHERE lock_version = :expectedVersion - Throws
OptimisticLockExceptionif 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:
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
}| Method | DbalStore | PostgresStore |
|---|---|---|
batchUpdate() | One UPDATE per aggregate | One UPDATE FROM VALUES for all |
| Optimistic locking in batch | Per-row, throws on conflict | Silent skip on version mismatch |
| Database portability | All DBAL drivers | PostgreSQL only |
Use PostgresStore when batch sizes exceed ~50 aggregates and you are on PostgreSQL. For smaller batches the difference is negligible.