testmonitor/eloquent-revisable

A Laravel package to track revisions of Eloquent models, allowing you to easily view and restore previous versions of your data.

Maintainers

Package info

github.com/testmonitor/eloquent-revisable

pkg:composer/testmonitor/eloquent-revisable

Statistics

Installs: 4

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

dev-main 2026-04-03 21:18 UTC

This package is auto-updated.

Last update: 2026-04-03 21:18:53 UTC


README

Latest Stable Version CircleCI StyleCI codecov License

A Laravel package that provides revision tracking for Eloquent models. Add the HasRevisions trait to any model to automatically snapshot its state on every change, with support for field filtering, relation snapshots, revision limits, rollbacks, and event hooks.

Table of Contents

Installation

Install the package via Composer:

$ composer require testmonitor/eloquent-revisable

Publish the config file and migration:

$ php artisan vendor:publish --provider="TestMonitor\Revisable\RevisableServiceProvider" --tag="config"
$ php artisan vendor:publish --provider="TestMonitor\Revisable\RevisableServiceProvider" --tag="migrations"

Once published, you can configure your user model, revision model, and name generator in config/revisable.php.

Run the migration to create the revisions table:

$ php artisan migrate

You're all set up now!

Usage

Add the HasRevisions trait to your Eloquent model and implement the getRevisionOptions method:

use TestMonitor\Revisable\Concerns\HasRevisions;
use TestMonitor\Revisable\RevisableOptions;

class Article extends Model
{
    use HasRevisions;

    public function getRevisionOptions(): RevisableOptions
    {
        return RevisableOptions::defaults();
    }
}

By default, a new revision is created every time the model is updated. The RevisableOptions fluent builder lets you control exactly what gets snapshotted and how.

Examples

Configuration

Each model can be configured individually through getRevisionOptions(), independently of the global settings in config/revisable.php.

Creating a revision on model creation

By default, revisions are only created on updates:

public function getRevisionOptions(): RevisableOptions
{
    return RevisableOptions::defaults()
        ->enableRevisionOnCreate();
}

Enabling and disabling revisioning

Accepts a boolean or a callable, evaluated at revision time — suitable for feature flags or any other runtime condition:

public function getRevisionOptions(): RevisableOptions
{
    return RevisableOptions::defaults()
        ->enabledWhen(fn () => Feature::active('revision-tracking'));
}

enabledWhen controls whether revisions are created at all for a model. To suppress revisioning temporarily for a specific operation, use withoutRevisioning() instead — see Suppressing revisioning.

Tracking specific fields

By default all fields are tracked. Use onlyFields to include a specific set, or exceptFields to exclude certain fields and track everything else:

// Include only these fields
return RevisableOptions::defaults()
    ->onlyFields('title', 'body', 'status');

// Or exclude specific fields and track everything else
return RevisableOptions::defaults()
    ->exceptFields('views', 'cached_at');

Tracking relation snapshots

Capture the state of related models alongside field values:

public function getRevisionOptions(): RevisableOptions
{
    return RevisableOptions::defaults()
        ->withRelations('tags', 'categories');
}

Warning: Rolling back a revision that includes relations will delete related records created after the snapshot was taken (or soft-delete them if the model uses SoftDeletes). Only opt in when you are prepared to handle this.

Excluding relations from restoration

Relations are always tracked when listed in withRelations(), but you can prevent specific relations — or all of them — from being restored during a rollback. This is useful when a relation is managed by another system or process and should not be overwritten on rollback.

Exclude specific relations:

public function getRevisionOptions(): RevisableOptions
{
    return RevisableOptions::defaults()
        ->withRelations('author', 'tags')
        ->withoutRestoringRelations('tags'); // tags are tracked but never restored
}

Or exclude all relations from restoration:

public function getRevisionOptions(): RevisableOptions
{
    return RevisableOptions::defaults()
        ->withRelations('author', 'tags')
        ->withoutRestoringRelations(); // no relations are restored on rollback
}

Excluded relations are still snapshotted and visible in diffs — only the restoration step is skipped.

Tracking many-to-many changes (optional)

Laravel does not fire model events for BelongsToMany or MorphToMany mutations (attach, detach, sync, toggle, updateExistingPivot), so the package cannot detect them automatically. Add the optional HasRevisionablePivots trait to make pivot changes trigger revisions:

use TestMonitor\Revisable\Concerns\HasRevisions;
use TestMonitor\Revisable\Concerns\HasRevisionablePivots;
use TestMonitor\Revisable\RevisableOptions;

class Article extends Model
{
    use HasRevisions, HasRevisionablePivots;

    public function getRevisionOptions(): RevisableOptions
    {
        return RevisableOptions::defaults()
            ->withRelations('tags');
    }
}

A revision is only triggered when the relation is listed in withRelations() and the operation results in an actual change. The withoutRevisioning() helper and the revisioning event continue to work as expected.

If you prefer not to use the built-in trait, laravel-pivot-events fires pivotAttached, pivotDetached, and pivotUpdated events on the parent model — hook into those and call $model->saveAsRevision() directly.

Limiting the number of stored revisions

Automatically prune the oldest revisions once the limit is reached:

public function getRevisionOptions(): RevisableOptions
{
    return RevisableOptions::defaults()
        ->limitRevisionsTo(10);
}

Living snapshots (replace instead of accumulate)

By default every save creates a new revision. When a model goes through many minor edits before reaching a stable state — such as a draft document — you may prefer to keep a single living snapshot that is overwritten on each save, rather than accumulating many interim revisions.

Use replaceWhen with a boolean or a callable that receives the model:

public function getRevisionOptions(): RevisableOptions
{
    return RevisableOptions::defaults()
        ->replaceWhen(fn ($model) => $model->status === 'draft');
}

When the condition is true the latest revision is updated in place; its identity (id, created_at) is preserved. When the condition is false a new revision is created as normal, so the transition out of draft becomes its own permanent entry in the history.

If no revision exists yet the first save always creates one, regardless of the condition.

The living snapshot captures the pre-save state, consistent with normal revision behaviour. After two saves in draft, the snapshot holds the state before the most recent save, which serves as the rollback point.

Custom revision naming

The default VersionNameGenerator names revisions sequentially (v1, v2, …). You can provide your own generator by implementing the NameGenerator contract and registering it in the options:

use TestMonitor\Revisable\Contracts\NameGenerator;

class TimestampNameGenerator implements NameGenerator
{
    public function generate(Model $model): string
    {
        return now()->toDateTimeString();
    }
}
public function getRevisionOptions(): RevisableOptions
{
    return RevisableOptions::defaults()
        ->nameRevisionUsing(new TimestampNameGenerator);
}

Pass null to disable automatic naming entirely:

return RevisableOptions::defaults()->nameRevisionUsing(null);

Reading revisions

Revisions are standard Eloquent models and can be queried directly on any revisionable model, or across all models using the built-in scopes.

Accessing revisions

All revisions are available via the revisions relationship:

$article = Article::find(1);

foreach ($article->revisions as $revision) {
    echo $revision->name . '' . $revision->created_at . PHP_EOL;
}

Use firstRevision and latestRevision to jump directly to either end of the history:

$article->firstRevision;
$article->latestRevision;

Querying revisions

Filter revisions by user or model using the built-in scopes:

// Revisions created by a specific user
$revisions = Revision::forUser($user)->get();

// All revisions for a specific model instance
$revisions = Revision::forModel($article)->get();

Reconstructing a model from a revision

Any revision can be reconstructed as a model instance reflecting the state at the time it was captured:

$snapshot = $article->firstRevision->toModel(); // an Article instance, not a live record
echo $snapshot->title;

Comparing revisions

Use diff() to compare two states and inspect what changed. It returns a Diff object with changes() (only differing fields and relations) and all() (everything, including unchanged).

// What changed between two revisions
$diff = $revision->diff();              // vs its predecessor
$diff = $revision->diff($other);        // vs a specific revision

// What changed between the current model and a revision
$diff = $article->diff();               // vs the latest revision
$diff = $article->diff($revision);      // vs a specific revision

The output of changes() contains field entries and relation entries in one flat array:

$changes = $diff->changes();

// Field: ['old' => mixed, 'new' => mixed]
$changes['title'];    // ['old' => 'Draft', 'new' => 'Published']

// Relation: ['added' => [...ids], 'removed' => [...ids], 'changed' => [...]]
$changes['tags'];     // ['added' => [4], 'removed' => [1], 'changed' => []]

Use all() to include fields and relations that did not change:

$all = $diff->all();

Saving revisions

Revisions are created automatically on every save. Use saveAsRevision() when you need a named snapshot or want to attach additional context.

Manually saving a revision

Save a named snapshot at any point without waiting for a model update, optionally with extra context:

$article->saveAsRevision('Before major refactor');

// Attach arbitrary key/value context via the properties argument
$article->saveAsRevision('Before major refactor', [
    'reason' => 'Restructuring content',
    'ticket' => 'PROJ-42',
]);

Properties are stored as JSON and available on the revision instance:

$revision->properties['ticket']; // 'PROJ-42'

Rolling back

Any revision can be used to restore a model — and its tracked relations — to an earlier state.

Rolling back to the latest revision

To roll back a model to its most recent revision:

$article->rollback(); // returns false if no revisions exist

Rolling back to a specific revision

To restore a model to any earlier revision, pass the revision instance directly:

$article->rollbackToRevision($article->firstRevision);

Disabling revision creation on rollback

By default, every rollback creates a new revision capturing the restored state. Disable this per model:

public function getRevisionOptions(): RevisableOptions
{
    return RevisableOptions::defaults()
        ->disableRevisionOnRollback();
}

Events & control

The package fires events before and after revisioning and rollback. These can be used to add behaviour, abort operations, or integrate with other systems. Individual saves can also be excluded from revision tracking.

Listening to events

The package fires four model events you can hook into directly or via an observer:

// Fires before a revision is created — return false to abort
Post::revisioning(function (Post $post): void {
    // ...
});

// Fires after a revision is created — access the revision via $post->latestRevision
Post::revisioned(function (Post $post): void {
    $post->notify(new PostRevisioned($post->latestRevision));
});

// Fires before a rollback — return false to abort
Post::rollingBack(function (Post $post): void {
    // ...
});

// Fires after a rollback
Post::rolledBack(function (Post $post): void {
    Cache::forget("post.{$post->id}");
});

An observer class is useful when handling multiple events on the same model:

class PostObserver
{
    public function revisioned(Post $post): void { ... }
    public function rolledBack(Post $post): void { ... }
}

// In a service provider:
Post::observe(PostObserver::class);

Suppressing revisioning

To run an operation without creating a revision:

$article->withoutRevisioning(function () use ($article) {
    $article->update(['views' => $article->views + 1]);
});

Tests

The package contains integration tests. You can run them using PHPUnit.

$ vendor/bin/phpunit

Changelog

Refer to CHANGELOG for more information.

Contributing

Refer to CONTRIBUTING for contributing details.

Credits

License

The MIT License (MIT). Refer to the License for more information.