Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Persistent Object Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. src/session.php (ezcPersistentSession) — this is the entire public API surface you'll use daily.
  2. src/definition/ — ezcPersistentCodeManager, ezcPersistentCacheManager, ezcPersistentObjectDefinition.
  3. src/persistent_object.php — the base class/interface your entities implement (getState() / setState()).
  4. 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.

Implementation Patterns

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.

Gotchas and Tips

  • No framework integration whatsoever. No service provider, no facade, no container hints, no PSR-4 autoloading of your entity/definition classes (the package's own 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.
  • Composer/PHP friction is the first real blocker, not the API. Version constraints are old, and 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.
  • Definitions are the schema contract, and mismatches fail late. A wrong column name or missing property shows up at query time, sometimes only on the write path. Keep a smoke test per entity that does a load + save roundtrip so definition drift is caught by CI, not production.
  • 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.
  • Don't mix transaction managers. 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.
  • Type mapping is explicit, not magic. Dates, booleans and nulls are the usual suspects: booleans frequently round-trip as 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.
  • Debugging: get the SQL out. 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.
  • Global state escape hatch. 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.
  • Extension points worth knowing. Custom 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.
  • Licensing. 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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings
coleus/apps
seampact/laravel
emmyconceptng/laravel-ng-postcode
jjsquady/round-robin