Skip to content

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.

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.

MethodEffect
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.

  1. Dispatches a stoppable Propulsion\Event\PreFlushEvent. A listener calling $event->stopPropagation() aborts the whole flush — flush() returns 0 and nothing is persisted.
  2. Partitions tracked entities into inserts, updates, and deletes using their own isNew() / isModified() / isDeleted(), or an explicit attach() override. An entity that’s neither new nor modified is skipped.
  3. Topologically sorts the distinct tables the tracked entities span by foreign-key dependency.
  4. 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.
  5. On success: commits, dispatches PostFlushEvent (carrying the affected-row count), clears the tracked-entity list, and returns the count.
  6. 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’s save() 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.

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 unset

Track 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.

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.

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.

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 UnitOfWork is a plain object you construct and discard. There’s no Propulsion::getUnitOfWork(), and no hook into Session::reset() at request boundaries the way instance pools have.
  • No statement batching. Each entity still issues its own INSERT or UPDATE; the win is ordering and atomicity, not fewer round trips. Bulk loading is a separate, lower-level path that can’t help here, since COPY/LOAD DATA cannot return generated primary keys.

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 trackingBaseObject already 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 $con and joins the caller’s transaction. flush() needs no new transaction API.
  • Model events — PSR-14 lifecycle events dispatch from BaseObject as usual during a flush.
  • Query cache invalidation — a flush goes through the same BasePeer write paths that own invalidation, so it’s inherited rather than reimplemented.