isolate/unit-of-work
A Laravel package that brings the Unit of Work pattern to your application. Group related operations into a single atomic unit, manage database transactions, and defer side effects such as events or jobs until the unit completes successfully.
isolate/unit-of-work is a standalone implementation of the Unit of Work pattern — an identity map plus change tracking with a commit() that flushes accumulated changes through a mapper you supply. It is not a Laravel package: there is no service provider, no facade, no config file, and no Eloquent integration.
Minimal steps:
composer require isolate/unit-of-work will very likely fail platform or dependency resolution (the illuminate/* constraints are pinned to the 5.0–5.1 era). Create a throwaway directory, try the install there, and if it fails, clone the repository and read the source instead of fighting the constraint solver.README.md → composer.json (namespace and autoload style) → the top-level classes in src/ → tests/. In a package this old with this few stars, the test suite is the only reliable documentation of the actual API. Look specifically for the unit-of-work entry class, the identity map, and whatever "entity definition"/registration object is used to teach it about your domain objects.insert, update, delete-style methods) — that interface is the integration point with your application.commit() produce one insert/update/delete set. That is the whole value proposition: writes are derived from the object graph rather than hand-written at each call site.Illustrative shape (verify names against the source — this package predates any convention you may assume):
$uow = new UnitOfWork($mapper, $entityDefinitions);
$from = $uow->getRepository(Stock::class)->find($fromId);
$to = $uow->getRepository(Stock::class)->find($toId);
$from->decrease($qty);
$to->increase($qty);
$uow->commit(); // single insert/update set
Wrap it behind your own interface. Nothing in controllers, jobs, or domain services should reference the package namespace directly. Define a UnitOfWorkInterface in your domain layer and make the package one adapter:
namespace App\Domain\Support;
interface UnitOfWorkInterface
{
public function register(Runnable $work): void;
public function commit(): void;
public function rollback(): void;
}
This keeps the exit ramp cheap — when the package becomes untenable (or never installs), you swap in an implementation over DB::transaction() without touching call sites.
Envelope each commit in a database transaction. The package's commit() orchestrates mapper calls; it does not by itself open a PDO transaction. Wrap it:
DB::transaction(function () use ($uow) {
$uow->commit();
});
That is what turns ordered inserts/updates/deletes into an actual atomic boundary. Without it you get exactly the partial-write failure mode the pattern exists to prevent.
Defer side effects until after commit. Do not queue jobs, send mail, or dispatch domain events inside the unit-of-work scope. Buffer them on the unit and flush after the transaction returns:
$uow->afterCommit(fn () => event(new StockTransferred($from, $to)));
If the package does not offer a hook (check the source), keep a small closure array alongside the mapper and drain it once DB::transaction returns. This avoids the classic "job fired for a row that was rolled back" bug.
One unit per application service call, not per request. A request-boundary unit holds the DB connection for the whole HTTP cycle, which grows lock duration and collapses throughput under concurrency. Open the unit at the top of a command/handler, commit at the end of it, then release.
Use the identity map to share aggregate instances. The point of loading through getRepository(...)->find() instead of direct hydration is that repeated lookups return the same object. Mutate in one place and the change is tracked once — no double-update when the repository is hit twice.
Test the boundary, not the package. Write behavioral tests that assert: (a) an exception inside the scope leaves no rows changed, (b) the side-effect closure does not fire on rollback, (c) mutating two aggregates produces exactly one update each. These tests are what protect you when you eventually replace the package.
commit() is not transactional by itself. The most common mistake is calling commit() outside DB::transaction() and assuming atomicity. Verify against the source that the mapper calls are all inside one enclosing transaction, or you will ship partial commits while believing you have a UoW.find() and commit(), the unit will not notice. Treat long-lived units as unsafe; open-commit-close.UnitOfWork over DB::transaction() with an afterCommit array, explicit aggregate registration, and after-commit event dispatch covers 90% of Laravel codebases and eliminates the ownership burden. The value of this package is the pattern, demonstrated in the source; the artifact itself is a reference implementation, not a dependency you should ship.How can I help you explore Laravel packages today?