contenir / contenir-db-model
Contenir Db Model
Requires
- php: ^8.1.0
- contenir/contenir-metadata: ^1.0
- laminas/laminas-db: ^2.20
- laminas/laminas-hydrator: ^4.15
- laminas/laminas-mvc: ^3.0
Requires (Dev)
- laminas/laminas-coding-standard: ~3.0.0
- phpunit/phpunit: ^9.3
This package is auto-updated.
Last update: 2026-05-10 10:10:20 UTC
README
A small Laminas-flavoured data-mapper layer that pairs immutable-ish entities
with table-backed repositories. Built on top of laminas-db,
laminas-hydrator and laminas-mvc, it provides:
- A
Contenir\Db\Model\Entity\AbstractEntitybase class with column metadata, modification tracking, lazy relation loading vialaminas-eventmanager, and array hydration. - A
Contenir\Db\Model\Repository\AbstractRepositorytable-gateway base class that wrapslaminas-dbSqloperations (insert,update,delete,select,find,findByField,findOne) and an opinionatedsave()helper that auto-detects insert vs. update from the entity's primary keys. - A
Contenir\Db\Model\Hydrator\RelationsHydratorthat can hydrate single or many-related entities, optionally through an intermediate join table. - Service-manager wiring (
ConfigProvider,Module) so the package is usable out of the box from alaminas-mvcor Mezzio application.
Requirements
- PHP
^8.1 laminas/laminas-db^2.20laminas/laminas-hydrator^4.15laminas/laminas-mvc^3.0contenir/contenir-metadata^1.0
Installation
composer require contenir/contenir-db-model
If you use the Laminas component installer the package will register itself automatically. Otherwise add the module to your application config:
// config/modules.config.php return [ // ... 'Contenir\\Db\\Model', ];
The component exposes a configured database adapter alias under the
model.adapter config key (defaulting to Laminas\Db\Adapter\Adapter).
Override this in your application config if you use a custom adapter service.
// config/autoload/db.global.php return [ 'model' => [ 'adapter' => 'My\\Custom\\Adapter', 'map' => [ // Optional repository → entity mapping. Used by RepositoryFactory. App\Model\Repository\UserRepository::class => App\Model\Entity\UserEntity::class, ], ], ];
Usage
1. Define an entity
Subclass AbstractEntity and declare column metadata. primaryKeys,
columns, and relations are the three properties the base class reads.
use Contenir\Db\Model\Entity\AbstractEntity; class UserEntity extends AbstractEntity { protected array $primaryKeys = ['id']; protected array $columns = [ 'id', 'email', 'name', 'created_at', ]; protected array $relations = [ 'orders' => [ 'type' => AbstractEntity::RELATION_MANY, 'column' => 'id', 'table' => [ 'class' => OrderRepository::class, 'column' => 'user_id', ], 'order' => ['created_at DESC'], ], ]; }
Entities support array-style construction, isset/unset, modification tracking and PHP serialisation:
$user = new UserEntity(['id' => 1, 'email' => 'a@example.com']); $user->name = 'Alice'; // Constructor populates via __set, so every supplied column is flagged // modified — this is what tells save(MODE_INSERT) which columns to // write. Repositories call markClean() (or synch()) after a successful // load/save, so entities returned from find()/findOne() report only the // caller's subsequent changes: // $user->getModifiedArrayCopy(); // // => ['id' => 1, 'email' => 'a@example.com', 'name' => 'Alice'] $user->getArrayCopy(); // full row, including null columns $user->getPrimaryKeys(); // ['id' => 1] — auto-increment PKs come back as null until saved
2. Define a repository
Subclass BaseRepository (or AbstractRepository for full control) and set
the table name. The factory wires the adapter, entity prototype and lookup
service for you.
use Contenir\Db\Model\Repository\BaseRepository; use Laminas\Db\Sql\TableIdentifier; class UserRepository extends BaseRepository { protected TableIdentifier|string|array|null $table = 'users'; }
Register the repository with RepositoryFactory:
// config/autoload/dependencies.global.php use Contenir\Db\Model\Repository\Factory\RepositoryFactory; return [ 'dependencies' => [ 'factories' => [ App\Model\Repository\UserRepository::class => RepositoryFactory::class, ], ], ];
By default RepositoryFactory resolves the entity by anchoring the
convention to the trailing class name and the \Repository\ namespace
segment, so App\Model\Repository\UserRepository resolves to
App\Model\Entity\UserEntity. Unrelated occurrences of "Repository"
elsewhere in the namespace are left alone. Override the convention with
the model.map config when your naming differs.
3. Query and persist
/** @var UserRepository $users */ $users = $container->get(UserRepository::class); $user = $users->findOne(['email' => 'a@example.com']); $user->name = 'Updated'; $users->save($user); // detects update vs insert from primary keys $new = $users->create(['email' => 'b@example.com', 'name' => 'Bob']); $users->save($new); // mode auto → insert; primary key is back-filled $users->delete(['id' => 42]);
findByField($column, $value) validates $column against the
declared columns of the entity prototype and rejects unknown values, so
caller-controlled column names cannot smuggle SQL into the predicate's
left-hand side.
$order arguments to find, findByField, and prepareSelect are
passed straight to Laminas\Db\Sql\Select::order(), which quotes
identifiers. Pass either a string ('name ASC'), a list
(['name', 'created_at DESC']), or an associative array
(['name' => 'ASC']). For raw SQL fragments, pass a
Laminas\Db\Sql\Expression instance explicitly.
save() accepts an explicit mode if you need to override the detection:
use Contenir\Db\Model\Repository\AbstractRepository; $users->save($user, AbstractRepository::MODE_INSERT); $users->save($user, AbstractRepository::MODE_UPDATE);
By default save() issues a single statement and applies the
auto-generated PK locally — no post-write SELECT. Pass refresh: true
when the entity needs to pick up DB-computed defaults, triggers or
concurrent writes; the INSERT/UPDATE and refresh SELECT are then
wrapped in a transaction:
$users->save($user, refresh: true);
For long-running scripts or when several statements need to be atomic,
wrap them with transactional(). Calls are re-entrant — nested
transactional() invocations join the outer transaction, and only the
outermost frame commits or rolls back:
$users->transactional(function () use ($users, $orders, $user, $order) { $users->save($user); $orders->save($order); });
Optimistic locking
Declare a versionColumn on an entity to opt into optimistic
concurrency control:
class WidgetEntity extends AbstractEntity { protected array $columns = ['id', 'name', 'version']; protected ?string $versionColumn = 'version'; }
save() then issues UPDATE … WHERE pk = ? AND version = :loaded,
bumps the version through nextVersion(), and throws
Contenir\Db\Model\Exception\StaleEntityException when the row no
longer matches its loaded version (concurrently updated or deleted).
Override AbstractEntity::nextVersion() for non-integer schemes
(e.g. microtime-based timestamps).
4. Lazy-loaded relations
Relations declared on the entity are fetched on first access. Internally the
entity emits a loadRelation event; RelationsHydrator attaches a listener
that pulls the related rows from the configured repository.
$user->orders; // triggers a SELECT on the orders repository
The hydrator caches identical FK lookups within its own lifetime, so iterating a result set in which many parent rows share the same FK target (e.g. fifty orders that all belong to one user) only issues one query per distinct target rather than one per row.
To avoid the classic N+1 pattern when each parent has a different FK,
batch-load the relations up front with preloadRelations():
$users = iterator_to_array($users->find()); $users->preloadRelations($users, ['orders', 'profile']); foreach ($users as $user) { foreach ($user->orders as $order) { // already in memory, no query issued } }
preloadRelations() issues one SELECT per relation with a WHERE … IN (…)
clause over the parent foreign-key values and assigns the matching rows
back onto each entity. It currently supports single-column relations
without via join tables; for relations with via tables (many-to-many)
fall back to the lazy-load path.
For many-to-many relationships, declare a via table:
'tags' => [ 'type' => AbstractEntity::RELATION_MANY, 'column' => 'id', 'table' => [ 'class' => TagRepository::class, 'column' => 'id', ], 'via' => [ 'table' => 'user_tag', 'column' => 'user_id', // column on the join table matching the owning row's key 'join' => 'tag_id', // column on the join table matching the related table's key ], ],
Exceptions
All exceptions thrown by the package implement
Contenir\Db\Model\Exception\ExceptionInterface. Concrete classes extend the
matching SPL exception:
Contenir\Db\Model\Exception\InvalidArgumentExceptionContenir\Db\Model\Exception\RuntimeException
Development
composer install composer test # run the unit and integration tests composer cs-check # check coding standards composer cs-fix # apply coding standards fixes composer test-coverage # generate clover.xml coverage report (needs xdebug or pcov)
The integration tests live under test/Integration/ and use an in-memory
SQLite database (via pdo_sqlite) so they run without external setup. Unit
tests live alongside the corresponding src directory under test/.
License
Released under the BSD-3-Clause license.