everzet/persisted-objects
A lightweight PHP library for persisting domain objects as event streams. It records state changes as events and rebuilds objects by replaying them, offering a simple event-sourced alternative to traditional ORM mapping. Use it to keep model changes explicit and auditable without a heavy persistence...
everzet/persisted-objects is a ~200-line library whose entire job is: a named bag of objects, keyed by getId(), that survives between test steps. Everything else (file storage, serialization, composition) is an implementation detail layered on top. Read it in that order.
composer require --dev everzet/persisted-objects
Read these three files first, in this order:
src/Repository.php (or ObjectRepository) — the whole contract: findAll(), find($id), save($object), remove($object), clear(). Five methods; this is the API you'll actually use.src/InMemoryRepository.php — the reference implementation. It's an array keyed by getId(). If you understand this, you understand the package.src/Identifiable.php — the one interface your domain objects must implement (getId()).Minimal working example:
use Everzet\PersistedObjects\Identifiable;
use Everzet\PersistedObjects\InMemoryRepository;
class User implements Identifiable
{
private $id;
private $email;
public function __construct(int $id, string $email)
{
$this->id = $id;
$this->email = $email;
}
public function getId(): int { return $this->id; }
public function getEmail(): string { return $this->email; }
}
$users = new InMemoryRepository();
$users->save(new User(1, '[email protected]'));
$users->save(new User(2, '[email protected]'));
count($users->findAll()); // 2
$users->find(1)->getEmail(); // "[email protected]"
Your first real use case is exactly the one it was built for: a Behat/Suite step like "Given the user ada exists" and a later step "When ada logs in" that needs the same User instance. Without a repository you end up stuffing objects into static properties or leaking them between Contexts. With one, the Given step save()s and the When step find()s by ID.
Wire it once, in the suite's bootstrap / container, and inject the repository, never the raw array.
Shared repository across Behat contexts. Register the repository in the suite's service container (Behat's Context constructor injection or a bootstrap.php that builds a Pimple/array container) and inject the same instance into every Context. This is the core pattern — the repository is the cross-context handshake.
// features/bootstrap/Container.php
$container['users'] = new InMemoryRepository();
$container['orders'] = new InMemoryRepository();
One repository per aggregate root. Don't build a god-repository. $users, $orders, $invoices as separate instances keeps findAll() costs trivial and keeps clear() scoped. Naming them after the aggregate makes step definitions read like domain code.
Treat getId() as the contract, not the object. Across a step boundary, pass the ID (parse it out of the step text, store it in a small SharedStorage, or just re-derive it). Passing object references between Contexts works in-process but breaks the moment you move to FileRepository or split the suite — the object you get back from find() is a different instance with the same ID.
Layer with composition when you need disk backing. The library ships a multiple/composite repository that fans writes out to child repositories and reads from the first. Use it to get "memory for speed during the scenario, file on disk for debugging/--rerun support" without changing any step code:
$users = new MultipleRepository([$inMemory, $fileBacked]);
Reset between scenarios, not between suites. InMemoryRepository accumulates forever in a long-lived process. Call clear() on every registered repository from an @BeforeScenario (or AfterScenario if you want to inspect state after failure) hook. Do it from one place that loops over the container — otherwise you'll add a new repository and forget its hook.
Seed fixtures in bootstrap, not in steps. Anything every scenario assumes (admin user, reference data) belongs in the bootstrap save() calls; scenario steps should only create scenario-specific objects. This keeps Given steps short and makes the base state obvious.
Swap the backend by environment, not by code. InMemoryRepository locally, FileRepository in CI when you need to debug a failure after the process has exited, or when a second process (a screenshot tool, a parallel worker) needs to read the state. Keep the choice in one factory function so steps never branch on environment.
File storage is PHP serialize(), and it is brittle. Rename a class, move a namespace, or change a property type and the previously written file fails to unserialize — often silently returning false, so find() starts returning null for no obvious reason. Delete the state file after touching domain classes, and never commit it.
Anonymous classes, closures, and resources cannot be persisted. If you're mixing MockObject or a Closure callback into a persisted aggregate, FileRepository will throw or corrupt the file. Keep mocks out of repositories — mock services, persist entities.
There is no locking. Two parallel Behat processes pointed at the same state file will clobber each other, and the failure looks like flaky missing data rather than a write race. Either give each worker its own file (suffix with getmypid() / TEST_TOKEN) or stay on InMemoryRepository for parallel runs.
No query API — you filter in PHP. findAll() returns everything; there's no findBy. That's fine at test-fixture scale, and it's a signal: if you want to write $users->findByEmail(...), put that helper on the repository wrapper or a small UserRepository decorator, don't bolt a query language onto this library.
find() likely returns null for a miss. Assert on it explicitly in steps (Assert::notNull($user, 'user not seeded')) — an unexplained Call to a member function on null three steps later is the classic symptom of an @BeforeScenario reset that also wiped your bootstrap seed data.
save() is upsert. Saving a second object with the same getId() replaces the first, which is exactly what you want for an edit step, and a silent footgun if your fixture getId() accidentally returns a constant (e.g. return 1; in a trait).
Unmaintained since 2018. It has no PHP 8+ type declarations and no active CI; expect deprecation noise and check it against your PHP version before relying on it. The realistic options are (a) pin and vendor it, (b) fork it — it's a few hundred lines and trivially rewritable, (c) replace it with a plain ArrayObject plus a serialize()-to-file helper you control. Given the surface area, (c) is often cheaper than fighting the abandoned package.
Extension points are just two interfaces. Implement Repository for a custom backend (Redis for parallel workers, a database for a shared suite), or implement the Serializer interface and pass it to FileRepository to get JSON/YAML state files you can actually read in a text editor while debugging a failure. Swapping in a JSON serializer is the single highest-value change if you use the file backend at all.
How can I help you explore Laravel packages today?