Doctrine ORM
Zero-SQL aggregate persistence via Doctrine ORM — AggregateRoot, OrmStore,
Doctrine ORM
vortos/vortos-persistence-orm is an optional module that adds Doctrine ORM support to the write side. Domain aggregates extend Vortos\PersistenceOrm\Aggregate\AggregateRoot — ORM annotations live directly on the aggregate class. toRow(), fromRow(), and hand-written SQL disappear entirely.
When to Use ORM vs DBAL
Both modules implement the same UnitOfWorkInterface. Your repository interfaces are pure domain contracts — no framework types, no extends. Choose based on your access patterns and team preferences.
| ORM | DBAL | |
|---|---|---|
| SQL required | None — Doctrine generates it | Full control via QueryBuilder |
| Aggregate mapping | Doctrine entity annotations | toRow() / fromRow() in a DbalMapper |
| Schema management | vortos:orm:diff → vortos:migrate | Hand-written migrations + vortos:migrate |
| Optimistic locking | #[ORM\Version] — Doctrine-managed | version column — store-managed |
| Bulk operations | Not provided | batchInsert, batchUpdate, batchUpsert |
| Custom queries | DQL or createQueryBuilder() | DBAL createQueryBuilder() |
| Use when | Entity model fits cleanly, no heavy bulk writes | Full SQL control, bulk operations, complex projections |
Domain AggregateRoot Is Untouched
Vortos\Domain\Aggregate\AggregateRoot has no Doctrine dependency. Vortos\PersistenceOrm\Aggregate\AggregateRoot extends it — your domain model stays infrastructure-free at the interface level.
Installation
composer require vortos/vortos-persistence-ormThe package is auto-discovered. No manual registration is required.
Setup Wizard
vortos/vortos-persistence-orm is listed in suggest in the framework meta-package — it is not installed by default. The setup wizard installs it automatically when you choose "PostgreSQL (Doctrine ORM)" during php vortos setup.
Do Not Use Both ORM and DBAL on the Write Side
Do not install both vortos-persistence-dbal and vortos-persistence-orm in the same application. Both register UnitOfWorkInterface and Connection::class — the last registered package wins and the other's unit of work becomes unreachable.
Environment Variables
VORTOS_WRITE_DB_DSN=pgsql://postgres:secret@write_db:5432/myappPersistenceOrmExtension reads vortos.persistence.write_dsn, which is set from VORTOS_WRITE_DB_DSN by the core persistence module.
Services Registered
EntityManager::class — shared, lazy
EntityManagerInterface::class — aliased to EntityManager
Connection::class — DBAL connection extracted from EntityManager
(shared with OutboxWriter for atomic writes)
OrmUnitOfWork::class — DBAL-level transaction boundary and worker-request isolation
UnitOfWorkInterface::class — aliased to OrmUnitOfWork
OrmMetadataCache::class — PSR-6 metadata cache pool (wired at compile time when Cache module is loaded)
OrmClearCacheCommand::class — vortos:orm:clear-cache (registered when Cache module is loaded)AggregateRoot
Extend Vortos\PersistenceOrm\Aggregate\AggregateRoot for any aggregate persisted via Doctrine.
use Vortos\PersistenceOrm\Aggregate\AggregateRoot;AggregateRoot is a #[MappedSuperclass] that adds a single field: private int $ormVersion = 0, annotated with #[ORM\Version]. Doctrine increments this column automatically on every flush() and detects concurrent modifications via UPDATE ... WHERE ormVersion = :expected.
Why a separate property? The domain AggregateRoot::$version field is private — Doctrine cannot access or manage it through reflection. $ormVersion solves this: it is managed by Doctrine via #[ORM\Version], and AggregateRoot overrides getVersion(), incrementVersion(), and restoreVersion() so that the framework's optimistic locking and Doctrine always read and write the same number. Your application code never touches $ormVersion directly.
use Doctrine\ORM\Mapping as ORM;
use Vortos\PersistenceOrm\Aggregate\AggregateRoot;
#[ORM\Entity]
#[ORM\Table(name: 'users')]
final class User extends AggregateRoot
{
#[ORM\Id]
#[ORM\Column(type: 'string', length: 36)]
private string $id;
#[ORM\Column(type: 'string', length: 255)]
private string $email;
private function __construct(UserId $id, string $email)
{
$this->id = (string) $id;
$this->email = $email;
}
public static function register(UserId $id, string $email): self
{
$instance = new self($id, $email);
$instance->incrementVersion();
$instance->recordEvent(new UserRegistered((string) $id, $email));
return $instance;
}
public function getId(): UserId { return UserId::fromString($this->id); }
public function getEmail(): string { return $this->email; }
}Do not declare ormVersion in your entity — it is inherited from AggregateRoot.
Call $this->incrementVersion() inside your create() or factory method. Doctrine initialises ormVersion at 0 and increments it on the first UPDATE. Calling incrementVersion() on creation sets the version to 1 on first persist, keeping the framework's version tracking consistent from the moment the aggregate is created.
OrmStore and #[UsesOrmEntity]
ORM repositories use the same composition pattern as DBAL repositories. Annotate your repository with #[UsesOrmEntity] and declare an OrmStore constructor argument — the compiler pass creates and injects the store automatically.
use Vortos\PersistenceOrm\Attribute\UsesOrmEntity;
use Vortos\PersistenceOrm\Write\OrmStore;
#[UsesOrmEntity(User::class)]
final class UserRepository implements UserRepositoryInterface
{
public function __construct(private readonly OrmStore $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 entityClass() method. The entity class is declared once in the attribute — the compiler pass wires an OrmStore configured for User::class.
Register
$services->set(UserRepository::class);OrmRepositoryCompilerPass detects #[UsesOrmEntity] at compile time and injects the configured store. No manual argument configuration needed.
How save() and delete() Work
save() uses $em->contains() to detect whether the aggregate is already managed by Doctrine's identity map:
- If the aggregate is not managed (new entity):
persist()+flush(). Doctrine issues anINSERT. - If the aggregate is managed (retrieved in the same unit of work):
flush()only. Doctrine's change tracking detects dirty fields and issues anUPDATEautomatically.
delete() also checks $em->contains() first. If the aggregate is not managed (for example, if it was constructed outside Doctrine), it calls find() to load a managed copy before calling remove().
Both save() and delete() translate all infrastructure exceptions so application code never imports Doctrine or DBAL types.
Exception Translation
OrmStore translates every DBAL and Doctrine exception to a type in Vortos\PersistenceOrm\Exception\:
| DBAL exception | Vortos exception |
|---|---|
UniqueConstraintViolationException | UniqueConstraintException |
ForeignKeyConstraintViolationException | ForeignKeyConstraintException |
NotNullConstraintViolationException | NotNullConstraintException |
DeadlockException | DeadlockException (safe to retry) |
LockWaitTimeoutException | LockWaitTimeoutException (safe to retry) |
ConnectionException | ConnectionException |
Any other Doctrine\DBAL\Exception | PersistenceException (base) |
DoctrineOptimisticLockException | Vortos\Domain\Repository\Exception\OptimisticLockException |
The original exception is always available via $e->getPrevious(). Non-DBAL exceptions (e.g. your own DomainException) pass through unwrapped.
use Vortos\PersistenceOrm\Exception\UniqueConstraintException;
try {
$this->users->save($user);
} catch (UniqueConstraintException $e) {
throw EmailAlreadyTakenException::forEmail($command->email);
}Pre-check vs catch
For constraints you can cheaply check before writing (e.g. email uniqueness), prefer an existsByEmail() pre-check over catching UniqueConstraintException. The catch pattern is appropriate for constraints that are hard or expensive to pre-check (e.g. composite uniqueness, foreign key references into tables you don't query directly).
Built-In Operations
| Method | Behaviour |
|---|---|
store->find(AggregateId $id) | EntityManager::find() — returns null if not found |
store->save(AggregateRoot $aggregate) | Detects new vs managed via contains(), persist + flush or flush only |
store->delete(AggregateRoot $aggregate) | Detects managed via contains(), falls back to find() if unmanaged, then remove() + flush() |
Custom Queries
For queries beyond findById(), use $this->store->createQueryBuilder() or $this->store->createQuery(). The EntityManager is fully encapsulated in the store:
#[UsesOrmEntity(User::class)]
final class UserRepository implements UserRepositoryInterface
{
public function __construct(private readonly OrmStore $store) {}
public function findByEmail(string $email): ?User
{
/** @var User|null */
return $this->store->createQueryBuilder()
->select('u')
->from(User::class, 'u')
->where('u.email = :email')
->setParameter('email', $email)
->getQuery()
->getOneOrNullResult();
}
}OrmUnitOfWork
OrmUnitOfWork implements UnitOfWorkInterface using DBAL-level transactions (beginTransaction / commit / rollBack). The API is identical to the DBAL UnitOfWork — no application code changes are needed when switching persistence backends.
use Vortos\Persistence\Transaction\UnitOfWorkInterface;
final class RegisterUserHandler
{
public function __construct(
private readonly UnitOfWorkInterface $unitOfWork,
private readonly UserRepositoryInterface $users,
) {}
public function __invoke(RegisterUser $command): User
{
return $this->unitOfWork->run(function () use ($command) {
$user = User::register(UserId::generate(), $command->email);
$this->users->save($user);
return $user;
});
}
}CommandBus Manages the Transaction Automatically
The CommandBus wraps every handler in UnitOfWork::run() automatically. Inject UnitOfWorkInterface directly only when you need explicit transaction boundaries outside of a command handler.
Why Not EntityManager::wrapInTransaction()?
Doctrine's wrapInTransaction() calls $em->close() in its finally block whenever any exception escapes the callback. In FrankenPHP worker mode, a single exception permanently kills the EntityManager for all subsequent requests in that worker thread. OrmUnitOfWork uses DBAL-level transactions to avoid this — the EM is never closed on normal exception paths.
Worker Mode Isolation
OrmUnitOfWork implements ResetInterface. In FrankenPHP worker mode, ServicesResetter calls reset() between requests. reset() calls $em->clear(), which detaches all entities from Doctrine's identity map.
If the EntityManager was closed by an unexpected code path, reset() also closes the underlying DBAL connection so that DBAL auto-reconnects on the next request — a safety net that should never be needed in practice, since DBAL-level transactions do not close the EM.
Connection Resilience
OrmUnitOfWork pings the database with SELECT 1 before beginning each transaction. If the ping fails — for example after a database restart or a long idle period in a worker process — it calls close() on the DBAL connection. DBAL reconnects automatically on the next query.
This prevents dead connection errors that would otherwise surface as fatal errors in long-running FrankenPHP workers after periods of inactivity.
Metadata Cache
In production with the Cache module loaded, OrmMetadataCache is registered automatically. It is a PSR-6 CacheItemPoolInterface adapter backed by Vortos TaggedCacheInterface. All ORM metadata entries are tagged with 'orm_metadata'.
OrmMetadataCachePass runs at compile time (after all extensions are merged) and wires the cache into the EntityManagerFactory. This happens via a compiler pass rather than inside the extension itself — extensions cannot see each other's services during load() due to Symfony's isolation mechanism, but compiler passes can.
The pass decides which path to take based on kernel.env and whether TaggedCacheInterface is registered:
Dev mode (kernel.env = dev): The metadata cache is bypassed entirely. Doctrine re-reads entity attributes on every request. Mapping changes are visible immediately without clearing any cache.
Prod mode (kernel.env = prod): Metadata is read once on first boot, stored in Redis via OrmMetadataCache, and served from Redis on all subsequent requests. Zero PHP attribute reflection occurs per-request — Doctrine reads the cached mapping data directly.
Clearing ORM Metadata
php vortos vortos:orm:clear-cacheThis command surgically invalidates only the orm_metadata tag. It does not touch flag cache, auth tokens, read models, or any other cache entries. Run it after changing entity mappings in a deployment.
vortos:cache:clear also invalidates ORM metadata (it clears everything tagged in the cache), but vortos:orm:clear-cache is preferable when only entity mappings changed and you want to avoid cache-warming cost on other entries.
Cache Module Required
OrmMetadataCache and vortos:orm:clear-cache are only registered when TaggedCacheInterface is available (i.e. the Cache module is loaded). In environments without the Cache module, Doctrine falls back to its default in-memory metadata handling.
Schema Management
Schema changes always go through the migration system — there is no direct SchemaTool::update path. This keeps every schema change reviewable, version-controlled, and rollback-able.
# 1. Make your entity changes
# 2. Preview the SQL diff without writing a file (useful before committing)
php vortos vortos:orm:diff --dry-run
# 3. Generate a migration from the diff against the live database
php vortos vortos:orm:diff
# 4. Review the generated migration file in migrations/
# 5. Run all pending migrations (ORM diff + framework module migrations in one command)
php vortos vortos:migrate--dry-run prints every SQL statement that would be written without creating a file. Use it to confirm the diff is what you expect before committing a migration to source control.
Both framework table migrations (published via vortos:migrate:publish) and ORM entity migrations (generated via vortos:orm:diff) land in the same migrations/ directory and are run together by vortos:migrate. One command covers everything.
See Running Migrations for the full workflow including preflight checks, advisory locking, and dry-run mode.
Smart Stubs
vortos:make:entity and vortos:make:write-repository auto-detect whether vortos-persistence-orm is installed and generate the correct pattern — no flags needed.
# Generates User extending AggregateRoot (with #[ORM\Entity], #[ORM\Table], #[ORM\Id])
php vortos vortos:make:entity User --context=User
# Generates UserRepository with #[UsesOrmEntity] and OrmStore injection
php vortos vortos:make:write-repository User --context=UserWhen ORM is detected, the command prints [ORM] next to the command name in the output. When only DBAL is installed, the #[UsesDbalMapper] / DbalStore pattern is generated instead.