yuriitatur / versions
Append-only versioning of repository entities
Requires
- php: ^8.4
- illuminate/collections: ^12.28 || ^13.0
- psr/event-dispatcher: ^1.0
- yuriitatur/exceptions: ^1.1
- yuriitatur/repository: dev-master
Requires (Dev)
- dg/bypass-finals: ^1.9
- orchestra/testbench: ^11.0
- phpunit/phpunit: ^13.0
- yuriitatur/laravel-logger-boost: ^1.2
- yuriitatur/repository-laravel: ^0.3
Suggests
- yuriitatur/laravel-logger-boost: Stores the log keys (uid, trace_id, parent_uid) with every version
- yuriitatur/repository-laravel: Required by the Laravel layer (stores, service provider, model snapshots)
Provides
None
Conflicts
None
Replaces
None
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
| Piece | Role |
|---|---|
Versionable | An entity with a version number and a content hash (HasVersion implements it). The main storage must persist both. |
VersionedRepository | A decorator around any RepositoryInterface: versions entities on save(), handles delete() and exposes the history. |
VersionStore | Append-only storage of Version records. RepositoryVersionStore stores them in any repository of Version entities. |
SnapshotExtractor / SnapshotRestorer | Read the versioned fields from an entity / put them back. HydratorSnapshotMapper does both with the entity's hydrator. |
VersionMetaProvider | Extra data stored with each version (who, from where, when...). |
A save:
- extracts the snapshot of the versioned fields and hashes it (key order independent),
- saves the entity as is when the hash did not change (non-versioned changes are still persisted),
- 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
DeletePolicy | On delete() |
|---|---|
Keep (default) | The versions stay. |
Cascade | The versions are deleted with the entity. |
Tombstone | A 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
| Key | Default | Description |
|---|---|---|
versions.store | table (VERSIONS_STORE) | table: TableVersionStore (query builder), model: ModelVersionStore (Eloquent) |
versions.table | versions | The versions table |
versions.connection | null | The connection of the versions table |
versions.model | VersionModel | The model of the model store, extend it to customize it |
versions.meta_providers | [LaravelMetaProvider::class] | Meta providers stored with every version |
versions.meta.guard | null | The 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:
| Key | Value |
|---|---|
user_id, user_type, guard | The authenticated user (its morph class for models) |
source | http when handling a routed request, console otherwise |
method, url, route, ip, user_agent, request_id | The routed request (request_id from the X-Request-Id header) |
uid, trace_id, parent_uid | The log keys of yuriitatur/laravel-logger-boost, when installed: search the logs by them to see everything around the change |
recorded_at | The 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