strukt / orm
An identity map, unit of work, and result cache over Strukt Ar models.
Requires
- php: >=8.2
- strukt/ar: dev-main
Requires (Dev)
- pestphp/pest: ^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-25 16:54:55 UTC
README
Managed entities over Strukt Ar: an
identity map, a unit of work, and result caching, layered on the very same
Strukt\Ar\Model classes and connection as the active record layer.
Strukt\Orm objects are the same entities you get from User::find() — created,
saved, and deleted through save()/delete() as usual. The manager only adds
managed behavior for the objects you route through it:
direct (strukt/ar) |
managed (strukt/orm) |
|
|---|---|---|
| Style | immediate, explicit writes | deferred, batched writes |
| Identity | one object per call | one object per row, always |
| Writes | save()/delete() run at once |
persist()/remove() flush on commit() |
| Reads | query builder per query | query builder + result cache |
| Use | low-level control | managed entities on a large workload |
Requirements
- PHP 8.2+
- Composer
strukt/ar(pulled in automatically) with its PDO driver
Install
composer require strukt/orm
orm() returns the shared manager; it resolves through the default connection, and
Manager::for('name') binds it to a named one. Manager::fromEnv() opens the
connection from .env file(s) first — see Strukt\Ar\Connection::fromEnv() — and
then returns the shared manager:
Manager::fromEnv('.env', '.env.local'); $sam = orm()->find(User::class, 7); // reads from the env-described connection
Identity map and unit of work
find() consults the identity map before touching the database, so a row always loads
into the same instance and repeated lookups cost nothing. persist() and remove()
only schedule work, and commit() flushes it all inside one transaction.
use Strukt\Orm\Manager; $sam = orm()->find(User::class, 7); // one instance per row $sam->name = 'Samantha'; // nothing written yet $draft = new User(); $draft->fill(['name' => 'Ada', 'email' => 'ada@strukt.test']); orm()->persist($draft); orm()->remove($sam); orm()->commit(); // one transaction: insert, update, delete
An untouched entity is not written: a snapshot taken when it was loaded is compared at
commit time, and only drifted columns are saved. A failed commit rolls the whole batch
back and rethrows the cause; the manager discards its tracked work, so reload affected
rows with find() afterwards. rollback() discards pending work without touching the
database, and lookup() peeks at the identity map without a query.
$sam = orm()->find(User::class, 7); orm()->lookup(User::class, 7); // the same instance, no round trip orm()->lookup(User::class, 99); // null, not yet loaded
Repositories
query() hands out a Repository that proxies the Ar query builder but hydrates
through the identity map, so query results share instances with find().
$admins = orm()->query(User::class) ->where('is_admin', 1) ->orderBy('name') ->get(); $first = orm()->query(User::class) ->where('email', $email) ->first();
Row results are cached by SQL and bindings, so re-running the same query reuses the
rows. The cache lives in the manager and is emptied by commit(), rollback(), and
clear() — so reads observe writes made through the manager, and, after the next
commit, writes made around it too. Aggregates such as count() and sum() always read
live.
Working with both layers
Installing strukt/orm brings strukt/ar in as a dependency, so both layers operate on the
same models, tables, and connection. They do not conflict, but three behaviors follow
from reusing Ar\Model and are part of the contract:
-
Ar writes do not invalidate the manager's result cache.
commit(),rollback(), andclear()are the only cache flushes. A directUser::create()or$user->save()bypasses the manager, so a repeatedorm()->query(User::class)->get()can serve rows that predate the write until the nextcommit()/clear(). Aggregates always read live; only row results are cached. -
One row can exist as two objects. Ar
find()/first()materialize fresh instances; only what goes throughorm()is identity-mapped. Editing a managed entity and then saving it through Ar writes immediately and leaves it dirty against its snapshot, socommit()issues a redundant update — and Ar-delete()on a tracked entity makescommit()try the delete again (a harmless zero-row change). -
Interleaved Ar writes ride the commit's transaction. An Ar
save()betweenpersist()andcommit()uses the same connection, so a failed commit rolls it back too — "already saved" can be undone by the failing batch.
In practice: pick one path per row or workflow. Route managed rows through orm(),
use strukt/ar directly for one-off or bulk work, and when the two touch the same rows, drop
state with orm()->clear() after Ar writes. Don't save()/delete() entities you
also intend to commit().
Tests
composer test
The suite runs against an in-memory SQLite connection with the same schema the strukt/ar
package uses and covers the identity map, unit of work, repositories, and result
cache.
License
MIT. See LICENSE.