Search by

yuriitatur / versions

yuriitatur

Append-only versioning of repository entities

v0.2 2026-10-30 06:26 UTC

This package is auto-updated.

Last update: 2026-10-30 06:27:57 UTC


README

Append-only versioning of entities stored with yuriitatur/repository. Every save that changes the versioned data of an entity appends a snapshot of it to a version store, so you can read its history, diff versions and revert to any of them. The core is framework agnostic; a Laravel layer provides version stores, a service provider and meta providers.

Installation

composer require yuriitatur/versions

For the Laravel layer, also:

composer require yuriitatur/repository-laravel

Core Concepts

PieceRole
VersionableAn entity with a version number and a content hash (HasVersion implements it). The main storage must persist both.
VersionedRepositoryA decorator around any RepositoryInterface: versions entities on save(), handles delete() and exposes the history.
VersionStoreAppend-only storage of Version records. RepositoryVersionStore stores them in any repository of Version entities.
SnapshotExtractor / SnapshotRestorerRead the versioned fields from an entity / put them back. HydratorSnapshotMapper does both with the entity's hydrator.
VersionMetaProviderExtra data stored with each version (who, from where, when...).

A save:

  1. extracts the snapshot of the versioned fields and hashes it (key order independent),
  2. saves the entity as is when the hash did not change (non-versioned changes are still persisted),
  3. otherwise, in a transaction: checks the entity is not stale, bumps its version, saves it and appends the version. When anything fails, the entity gets its previous version and hash back, so the save can be retried.

Usage

use YuriiTatur\Versions\HasVersion;
use YuriiTatur\Repository\Utilities\HasId;
use YuriiTatur\Versions\Contracts\Versionable;

class Document implements Versionable
{
    use HasId;
    use HasVersion; // persist its versionNumber and versionHash fields

    public function __construct(public string $title = '', public string $body = '') {}
}
use YuriiTatur\Versions\Hydration\VersionHydrator;
use YuriiTatur\Versions\Repositories\DeletePolicy;
use YuriiTatur\Versions\Snapshots\HydratorSnapshotMapper;
use YuriiTatur\Versions\Repositories\VersionedRepository;
use YuriiTatur\Versions\Repositories\RepositoryVersionStore;

$mapper = new HydratorSnapshotMapper($documentHydrator, ['title', 'body']); // the versioned fields

$documents = new VersionedRepository(
    $documentRepository,                                                     // any RepositoryInterface
    new RepositoryVersionStore(new EntityRepository($driver, new VersionHydrator, $events)),
    $mapper,
    $mapper,
    'document',                                                              // the entity type of its versions
    transactions: $transactionRunner,                                        // NullTransactionRunner by default
    events: $eventDispatcher,                                                // optional
    metaProvider: $metaProvider,                                             // optional
    deletePolicy: DeletePolicy::Keep,
);

$documents->save($document);                 // version 1
$document->title = 'New title';
$documents->save($document);                 // version 2

$documents->history($document);              // Collection<Version>, ordered by number
$documents->getVersion($document, 1);        // Version: getSnapshot(), getMeta(), getCreatedAt()...
$documents->diff($document, 1, 2);           // ['title' => ['from' => ..., 'to' => ...]]
$documents->revert($document, 1);            // restores version 1 into $document, saved as version 3
$documents->prune($document, keepLast: 10);  // deletes the older versions

Queries (where(), getById(), count()...) go to the decorated repository.

HydratorSnapshotMapper needs a hydrator that dehydrates entities to arrays and hydrates them back from plain (JSON) values. For Eloquent models, use the Laravel ModelSnapshotMapper.

Conflicts

Saving an entity whose version is not the latest stored one throws VersionConflictException (HTTP 409): it was changed since it was read. Reload it and apply the change again. Give the version storage a unique index on (entity_type, entity_id, version) to also catch concurrent writes; the Laravel stores turn its violations into VersionConflictException.

Deleting

DeletePolicyOn delete()
Keep (default)The versions stay.
CascadeThe versions are deleted with the entity.
TombstoneA version without snapshot is appended (Version::isTombstone()); reverting to it fails.

Events

With an event dispatcher, VersionCreated is dispatched after each new version (and tombstone), EntityReverted after a revert.

Meta

metaProvider adds data to every version. NullMetaProvider adds none, CombinedMetaProvider merges several providers, an earlier provider keeping its keys over the next ones.

Laravel

The VersionsServiceProvider is auto-discovered. It needs the RepositoryServiceProvider of yuriitatur/repository-laravel (auto-discovered too).

php artisan vendor:publish --tag=versions-config
php artisan vendor:publish --tag=versions-migrations
php artisan migrate

The migration creates the versions table with its unique index.

Configuration

KeyDefaultDescription
versions.storetable (VERSIONS_STORE)table: TableVersionStore (query builder), model: ModelVersionStore (Eloquent)
versions.tableversionsThe versions table
versions.connectionnullThe connection of the versions table
versions.modelVersionModelThe model of the model store, extend it to customize it
versions.meta_providers[LaravelMetaProvider::class]Meta providers stored with every version
versions.meta.guardnullThe auth guard of LaravelMetaProvider, the default one when null

The container binds VersionStore (by versions.store) and VersionMetaProvider (a CombinedMetaProvider of the configured providers followed by the services tagged with VersionsServiceProvider::META_PROVIDERS_TAG).

Versioning Eloquent models

A model acting as its own entity implements Versionable with HasVersionAttributes, which keeps the version in the version and content_hash columns (override getVersionColumn() / getContentHashColumn() to rename them):

class Document extends Model implements Versionable
{
    use HasVersionAttributes;

    public function getId(): int|string|null|\Stringable { return $this->getKey(); }
    public function setId(int|string|null|\Stringable $id): static { $this->setAttribute($this->getKeyName(), $id); return $this; }
}
use YuriiTatur\Versions\Contracts\VersionStore;
use YuriiTatur\Versions\Contracts\VersionMetaProvider;
use YuriiTatur\Versions\Laravel\Snapshots\ModelSnapshotMapper;
use YuriiTatur\Repository\Contracts\TransactionRunnerInterface;
use YuriiTatur\Repository\Laravel\Services\EloquentRepositoryBuilder;

$this->app->singleton(DocumentRepository::class, function ($app) {
    $mapper = new ModelSnapshotMapper(['title', 'body']);

    return new VersionedRepository(
        EloquentRepositoryBuilder::create(Document::class)->build(),
        $app->make(VersionStore::class),
        $mapper,
        $mapper,
        'document',
        transactions: $app->make(TransactionRunnerInterface::class),
        metaProvider: $app->make(VersionMetaProvider::class),
    );
});

For entities mapped by a HydratorModelInterface model, pass the model: new ModelSnapshotMapper(['title', 'body'], DocumentModel::class). Its fromEntity() / toEntity() must map the version and the content hash too.

Snapshots hold the attributes as the model serializes them (attributesToArray(), casts applied).

Meta providers

LaravelMetaProvider records:

KeyValue
user_id, user_type, guardThe authenticated user (its morph class for models)
sourcehttp when handling a routed request, console otherwise
method, url, route, ip, user_agent, request_idThe routed request (request_id from the X-Request-Id header)
uid, trace_id, parent_uidThe log keys of yuriitatur/laravel-logger-boost, when installed: search the logs by them to see everything around the change
recorded_atThe time of the change, with microseconds

Absent values are left out. The provider is Octane safe: it reads the request, the user and the log keys from the current application (Container::getInstance(), the sandbox of the request under Octane) on every call, so it can be injected into singletons. Add your own providers to versions.meta_providers, or tag them:

$this->app->tag(TenantMetaProvider::class, VersionsServiceProvider::META_PROVIDERS_TAG);

Use NullMetaProvider ('meta_providers' => [] does the same) to store no meta, in tests for instance.

Octane

The service provider only registers singletons that hold no request state: the stores reach the database through facades, and LaravelMetaProvider resolves the current application on every call. Keep your own meta providers the same way (no request, user or container captured in the constructor) when you register them as singletons.

Testing

composer test

Unit tests (tests/unit) run on the in-memory driver; feature tests (tests/feature) run the Laravel layer with Orchestra Testbench on SQLite.

License

MIT