Unit of Work
Propulsion\UnitOfWork is an optional layer on top of the ordinary Active Record API. You register the objects you want persisted, then call flush() once: it partitions them into inserts, updates, and deletes, orders them so parent tables are written before the tables that reference them, and writes the whole batch inside a single transaction.
You never have to use it. Calling save() and delete() yourself works exactly as it always has — this exists for the cases where “save each of these thirty objects in the right order, all or nothing” is what you actually mean.
Basic usage
Section titled “Basic usage”use Propulsion\UnitOfWork;use Propulsion\Propulsion;
$con = Propulsion::getConnection(BookPeer::DATABASE_NAME, Propulsion::CONNECTION_WRITE);$uow = new UnitOfWork($con);
$author = new Author();$author->setFirstName('Leo');$author->setLastName('Tolstoy');
$book = new Book();$book->setTitle('War And Peace');$book->setAuthor($author);
$uow->track($author);$uow->track($book);
$affected = $uow->flush();// One transaction: INSERT author, then INSERT book with the author's new id.The author is inserted before the book because book has a foreign key into author — you didn’t have to say so, and you didn’t have to save the author first yourself.
The API
Section titled “The API”| Method | Effect |
|---|---|
track(BaseObject $entity) | Register an entity to be flushed. Idempotent by object identity — tracking the same instance twice does nothing. |
markDeleted(BaseObject $entity) | Register an entity to be deleted on flush. Shorthand for attach($entity, EntityState::Deleted). |
attach(BaseObject $entity, EntityState $state) | Track an entity and override how flush() treats it. See Detached entities. |
detach(BaseObject $entity) | Stop tracking an entity. It’s left exactly as-is and simply won’t be visited by the next flush(). |
getTrackedEntities() | Every currently-tracked entity, in no particular order. |
flush() | Write the batch. Returns the total affected-row count. |
EntityState is an enum with four cases: Added, Modified, Deleted, and Unchanged.
What flush() does
Section titled “What flush() does”- Dispatches a stoppable
Propulsion\Event\PreFlushEvent. A listener calling$event->stopPropagation()aborts the whole flush —flush()returns0and nothing is persisted. - Partitions tracked entities into inserts, updates, and deletes using their own
isNew()/isModified()/isDeleted(), or an explicitattach()override. An entity that’s neither new nor modified is skipped. - Topologically sorts the distinct tables the tracked entities span by foreign-key dependency.
- Opens one transaction, suppresses each entity’s automatic cascade, and calls each entity’s own
save($con)/delete($con)in dependency order: inserts and updates together, parent tables first, then deletes in reverse order. - On success: commits, dispatches
PostFlushEvent(carrying the affected-row count), clears the tracked-entity list, and returns the count. - On any
PropulsionException: rolls back and rethrows. Tracked entities are left tracked, so you can inspect what failed and retry — the same contract a single object’ssave()has.
The ordering is table-level, not per-instance. Inserts and updates happen parent-table-first exactly once across the whole batch, rather than via each root object’s own depth-first cascade.
Cycles are tolerated. A self-referencing foreign key (a tree’s parent_id) is common and harmless. A genuine cross-table cycle has its back edge ignored rather than aborting the sort — best-effort, and no worse than the per-object cascade it replaces.
Cascade is replaced, not extended
Section titled “Cascade is replaced, not extended”This is the one thing to internalize before using UnitOfWork on a real object graph.
Ordinarily, save() cascades: it eagerly saves modified or new foreign-key parents first, then the row itself, then referrer collections. During flush(), that cascade is suppressed on every entity being written — because ordering the whole batch correctly is precisely what UnitOfWork is for, and running both mechanisms would double the work.
The consequence: an entity reachable only through another tracked entity’s foreign-key setter or collection, and not itself tracked, is never persisted.
$author = new Author();$book = new Book();$book->setAuthor($author);
$uow->track($book); // only the book$uow->flush(); // the author is NOT inserted, and book.author_id stays unsetTrack everything that needs to be written, not just the roots. There is no automatic graph discovery — track() registers exactly the instance you pass it and does not walk setters or collections looking for related objects.
One part of the cascade does survive suppression: the foreign-key re-sync. $book->setAuthor($author) re-reads the author’s current primary key into book.author_id unconditionally, so a child row picks up its parent’s just-generated id even though the parent’s own recursive save() was suppressed. Without that, ordering the batch correctly would accomplish nothing.
The mechanism is BaseObject::setSuppressAutoCascade(bool) / isSuppressAutoCascade(), false by default, so a plain save() outside a flush behaves exactly as before.
Detached entities
Section titled “Detached entities”A BaseObject you built yourself — hydrated from a deserialized API request body, say — always reports isNew() === true, whether or not it represents a row that already exists. attach() overrides the decision:
use Propulsion\EntityState;
$book = new Book();$book->setId(42); // we know this row exists$book->setTitle('Anna Karenina');
$uow->attach($book, EntityState::Modified); // update, don't insert$uow->flush();EntityState::Added sets isNew(true); EntityState::Modified sets isNew(false). Note that Modified only overrides the insert-vs-update decision — the entity still needs modified columns set through its own setters for there to be anything to write.
Errors and rollback
Section titled “Errors and rollback”Any PropulsionException from an entity’s save() or delete() rolls back the whole batch and is rethrown. That includes a ConcurrencyException from an optimistic-locked row: a stale row late in the batch undoes an otherwise-valid insert earlier in it.
Tracked entities survive the rollback, so a retry loop can re-read and flush again:
try { $uow->flush();} catch (ConcurrencyException $e) { $stale = $e->getEntity(); // ...refresh $stale, then flush() the same UnitOfWork again.}This works the same way whether flush() is the outermost transaction or is nested inside one you opened yourself: every supported platform has real savepoints, so a nested flush’s rollback undoes only the flush’s own writes and leaves your outer transaction committable. See Transactions.
Limitations
Section titled “Limitations”These are current scope boundaries, not workarounds:
- One connection, one database. Every tracked entity is written through the connection passed to the constructor, regardless of which configured datasource its table actually belongs to. Entities spanning more than one database in a single flush aren’t supported.
- No automatic graph discovery. See above.
- No request-scoped instance. A
UnitOfWorkis a plain object you construct and discard. There’s noPropulsion::getUnitOfWork(), and no hook intoSession::reset()at request boundaries the way instance pools have. - No statement batching. Each entity still issues its own
INSERTorUPDATE; the win is ordering and atomicity, not fewer round trips. Bulk loading is a separate, lower-level path that can’t help here, sinceCOPY/LOAD DATAcannot return generated primary keys.
What it builds on
Section titled “What it builds on”UnitOfWork adds ordering and batching on top of machinery that was already there, which is why it’s additive rather than a second persistence layer:
- Dirty tracking —
BaseObjectalready tracks$modifiedColumns,isModified(),isNew(),isDeleted(). - Identity map — request-scoped instance pooling via
Session, so the same primary key returns the same PHP instance within a request. - Nested transactions — every generated
save()/delete()already accepts an optional?PropulsionPDO $conand joins the caller’s transaction.flush()needs no new transaction API. - Model events — PSR-14 lifecycle events dispatch from
BaseObjectas usual during a flush. - Query cache invalidation — a flush goes through the same
BasePeerwrite paths that own invalidation, so it’s inherited rather than reimplemented.