zetacomponents/persistent-object
PersistentObject is a lightweight data persistence framework for PHP that maps objects to database tables. It provides CRUD operations, a fluent query API, and support for one-to-many and many-to-many relations, letting you work with rows as plain objects while it handles SQL generation.
What you're adopting. zetacomponents/persistent-object is the Apache Zeta Components ORM (a.k.a. the eZ Components PersistentObject layer). It predates PSR-4, Composer-first design, and Laravel. It is not an Eloquent replacement — it's a hand-rolled data mapper: you describe tables in definition files, write plain PHP entity classes, and a central ezcPersistentSession performs load / save / find / delete against an ezcDbHandler connection. With 4 stars, zero dependents and a NOASSERTION license, treat it as a maintenance liability you plug into a legacy schema, not a greenfield choice.
Install and verify it even loads on your PHP.
composer require zetacomponents/persistent-object
Expect this to require zetacomponents/base and zetacomponents/database, and expect the version constraints to be written for PHP 5.x. On Laravel 10/11 (PHP 8.2+) you may need --ignore-platform-reqs or, more realistically, a pinned fork in repositories. Before writing a line of application code, run composer show zetacomponents/persistent-object and confirm the package actually registers classes — it uses classmap/ezc* names, so there is no service provider, no facade, and no auto-discovery.
Where to look first. In order:
src/session.php (ezcPersistentSession) — this is the entire public API surface you'll use daily.src/definition/ — ezcPersistentCodeManager, ezcPersistentCacheManager, ezcPersistentObjectDefinition.src/persistent_object.php — the base class/interface your entities implement (getState() / setState()).tests/ — the canonical, working examples of definitions, relations and find queries. They are more current than most blog posts about this library.First use case: read one legacy table through a Laravel service provider. Bind the session as a singleton so you neither reconnect nor re-parse definitions per request:
// app/Providers/PersistentObjectServiceProvider.php
public function register(): void
{
$this->app->singleton(\ezcPersistentSession::class, function () {
$c = config('database.connections.legacy');
$db = \ezcDbFactory::create(sprintf(
'mysql://%s:%s@%s/%s',
urlencode($c['username']), urlencode($c['password']), $c['host'], $c['database']
));
return new \ezcPersistentSession(
$db,
new \ezcPersistentCacheManager(
new \ezcPersistentCodeManager(resource_path('persistent-object/definitions'))
)
);
});
}
Then a definition file (resources/persistent-object/definitions/LegacyContact.php) mapping the table, an entity class extending ezcPersistentObject with public properties, and a one-line $session->load(LegacyContact::class, 42) in a controller. That end-to-end slice — config → session → definition → class → load — is the whole learning curve; everything after it is variations.
Hide the session behind repositories. The single highest-leverage pattern: never let ezcPersistentSession, ezcQuerySelect or ezcPersistentObject* exceptions leak into controllers or jobs. Wrap each aggregate in a plain repository and translate ezcPersistentObjectNotFoundException into null/ModelNotFoundException.
final class LegacyContactRepository
{
public function __construct(private \ezcPersistentSession $session) {}
public function find(int $id): ?LegacyContact
{
try {
return $this->session->load(LegacyContact::class, $id);
} catch (\ezcPersistentObjectNotFoundException) {
return null;
}
}
public function byName(string $name): array
{
$q = $this->session->createFindQuery(LegacyContact::class);
$q->where($q->expr->eq('name', $q->bindValue($name)));
$q->orderBy('name');
return $this->session->find($q); // array of LegacyContact
}
public function persist(LegacyContact $c): void
{
$this->session->save($c); // INSERT or UPDATE based on the id property
}
}
Reads: load vs find vs update. load() is by primary key and throws on miss. find() takes a criteria object built by createFindQuery() and returns a collection (plain array). For reports over legacy tables, build the query with join/innerJoin, groupBy and orderBy on the same object rather than pulling rows and filtering in PHP — the criteria object compiles to SQL and is the only place you get real pagination (limit/offset).
Writes: save for upsert semantics, update for explicit updates, transactions through the session. save() decides INSERT vs UPDATE from whether the identifier property is populated; if you're writing bulk importers, be aware of that heuristic and prefer explicit update() where you know the row exists. Wrap multi-row work in the session's own transaction methods — they delegate to the same ezcDbHandler you gave the session:
$this->session->beginTransaction();
try {
foreach ($rows as $row) { $this->session->save($row); }
$this->session->commit();
} catch (\Throwable $e) {
$this->session->rollback();
throw $e;
}
Relations are lazy by default. $session->getRelatedObjects($contact, 'Addresses') issues a query per object — the classic N+1. When you know you need children for a page of parents, invert the direction with a relation find query (createRelationFindQuery()) or a joined createFindQuery() and group in PHP. Use addRelatedObject() / removeRelatedObject() for writes, and remember they hit the join table directly, not your entity's array.
Integration strategy: read layer, not replacement. The pragmatic Laravel pattern with this package is strangler fig: keep the legacy schema, model a handful of tables with PersistentObject, expose repositories, and sync/normalize into Eloquent models on the boundary (importers, read models, or an API resource layer). Don't try to make PersistentObject entities behave like Eloquent models — no observers, no casts, no $fillable, no relationship properties.
Testing: point the session at SQLite. ezcDbFactory::create('sqlite://:memory:') (or a file DB) lets you exercise repositories in fast tests without MySQL. Seed with raw PDO/SQL through the same ezcDbHandler you hand the session so schema and definitions can't drift apart. If your tests use Laravel's RefreshDatabase, keep the legacy connection separate and migrate its tables explicitly — Laravel's transaction wrapper won't see the session's connection.
Config-driven definitions + cache manager. Always wrap ezcPersistentCodeManager in ezcPersistentCacheManager; without it, definitions are re-read from disk on every session interaction. In Laravel, that cache is per-request, so this is a cheap but real win on pages that load dozens of entities.
ezc* classes are classmapped). You wire all of it yourself; register a composer.json classmap entry or an explicit autoload.files for your definition directory if definitions are loaded by convention.zetacomponents/base tends to be the pinch point. If install fails on PHP 8.x, decide early between --ignore-platform-reqs, a pinned fork, or not adopting it — don't discover this mid-sprint.save() is not update(). The INSERT-vs-UPDATE decision depends on the identifier property and generator configuration; a "saved" object with a manually assigned ID can produce surprising SQL. Log the generated SQL (see below) whenever a write looks wrong.DB::transaction() on Laravel's PDO does nothing for queries issued by ezcDbHandler. Every write path touching legacy tables must use $session->beginTransaction()/commit()/rollback() so rollback actually rolls back. Also avoid holding a Laravel transaction open across a legacy write unless both point at the same PDO connection — they usually don't.0/1, dates as strings. Check whether your version ships property converters (e.g. a DateTime converter) and register one on the property definition rather than sprinkling strtotime() in entities.ezcDbHandler is PDO-based in the common handlers, so PDO::ATTR_ERRMODE => ERRMODE_EXCEPTION and a PDO statement-log wrapper get you the generated SQL and stack traces. For criteria objects, (string) $query on the ezcQuerySelect instance usually dumps the compiled SQL — invaluable when a find() returns nothing.ezcPersistentSessionInstance::set($session) exists for legacy code that calls session helpers statically. Use it only inside the service provider for backward-compatible code paths; new code should inject ezcPersistentSession so tests can substitute an in-memory session.ezcPersistentDefinitionManager (e.g. definitions from Laravel config instead of PHP files), custom ezcPersistentIdentifierGenerator for non-auto-increment keys, and property converters for type mapping. These are the three places to invest if you must live with the package long-term.NOASSERTION means Composer/your legal scan can't confirm terms. Resolve this (the upstream is Apache-2.0 licensed historically) before shipping it in anything customer-facing.How can I help you explore Laravel packages today?