avocet-shores/laravel-rewind

Version control for Eloquent models with hybrid diff and snapshot storage.

Maintainers

Package info

github.com/avocet-shores/laravel-rewind

pkg:composer/avocet-shores/laravel-rewind

Fund package maintenance!

Avocet Shores

Statistics

Installs: 10 623

Dependents: 0

Suggesters: 0

Stars: 197

Open Issues: 18

v0.9.0 2026-03-31 07:04 UTC

README

Latest Version on Packagist GitHub Tests Action Status Coverage Status GitHub Code Style Action Status Total Downloads

Full version control for your Eloquent models. Rewind, fast-forward, restore, diff, and query point-in-time state.

Under the hood, Rewind stores a mix of partial diffs and full snapshots. You get the storage efficiency of diffs with the reconstruction speed of snapshots, and the interval is configurable to suit your needs.

use AvocetShores\LaravelRewind\Facades\Rewind;

$post->update(['title' => 'Updated Title']);

Rewind::rewind($post);       // Back to 'Old Title'
Rewind::fastForward($post);  // Forward to 'Updated Title'
Rewind::goTo($post, 3);      // Jump to any version
Rewind::restore($post, 1);   // Create a new version from v1's state

Why Rewind?

  • Hybrid storage engine. Diffs between snapshots keep storage small. Snapshots at configurable intervals keep reconstruction fast. You control the trade-off.
  • Thread-safe. Cache-based locking prevents version sequence breaks during concurrent writes.
  • Non-destructive history. Edits on older versions, restores, and pruning all preserve the full audit trail.
  • Batch versioning. Group changes across multiple models into a single logical revision.
  • Built for real workloads. Queued version creation, automatic pruning, and a cost-based approach engine that picks the fastest reconstruction path.

Installation

composer require avocet-shores/laravel-rewind

Publish and run the migrations, and publish the config:

php artisan vendor:publish --provider="AvocetShores\LaravelRewind\LaravelRewindServiceProvider"
php artisan migrate

Getting Started

1. Add the trait

use AvocetShores\LaravelRewind\Traits\Rewindable;

class Post extends Model
{
   use Rewindable;
}

2. Add the current_version column

php artisan rewind:add-version

This generates a migration that adds current_version to your model's table. Run php artisan migrate to apply it.

That's it. Your model's changes are now tracked automatically.

Navigating History

use AvocetShores\LaravelRewind\Facades\Rewind;

// Step backward/forward
Rewind::rewind($post, 2);    // Go back 2 versions
Rewind::fastForward($post);  // Go forward 1 version

// Jump to a specific version
Rewind::goTo($post, 5);

// Get the model's state at a specific point in time
$attributes = Rewind::versionAt($post, Carbon::parse('2025-01-15 14:30:00'));

Restoring State

There are two ways to go back to a previous version, and the distinction matters:

goTo() moves the pointer. The model is updated to match the target version, but no new version record is created. Good for previewing or navigating.

restore() creates a new version with the target version's state. The history shows the restore happened. Good for audit trails and compliance.

// Move the pointer (no audit trail of the move itself)
Rewind::goTo($post, 3);

// Create a new version from v3's state (audit trail preserved)
Rewind::restore($post, 3);
// $post is now at v8 (or whatever the next version is), with v3's attributes
// The version record has event_type 'restored' and meta['restored_from_version'] = 3

Inspecting Changes

Version history

$versions = $post->versions;

Diff between two versions

$diff = Rewind::diff($post, 1, 5);

$diff->changed;   // ['title' => ['old' => 'Draft', 'new' => 'Published']]
$diff->added;     // Attributes only in v5
$diff->removed;   // Attributes only in v1
$diff->isEmpty(); // false

Works in either direction. diff($post, 5, 1) swaps old and new.

Build a specific version's attributes

Diffs don't always contain all the data for a version. This method reconstructs the full attribute set:

$attributes = Rewind::getVersionAttributes($post, 7);

Clone a model at a version

$clone = Rewind::cloneModel($post, 5);

Query scopes

use AvocetShores\LaravelRewind\Models\RewindVersion;
use AvocetShores\LaravelRewind\Enums\VersionEventType;

RewindVersion::forModel($post)->get();
RewindVersion::byUser($userId)->get();
RewindVersion::ofType(VersionEventType::Updated)->get();
RewindVersion::betweenDates($startDate, $endDate)->get();
RewindVersion::betweenVersions(1, 10)->get();

// Chain them together
RewindVersion::forModel($post)
    ->ofType(VersionEventType::Updated)
    ->byUser($userId)
    ->get();

Controlling What's Tracked

Exclude attributes

public static function excludedFromVersioning(): array
{
    return ['password', 'api_token'];
}

Amend the current version

Sometimes you want to save a change without creating a new version. Maybe you're bumping a counter or syncing a denormalized field.

Rewind::amendCurrentVersion(function () {
    $post->update(['view_count' => $post->view_count + 1]);
});

The changed attributes are folded into the current version's old_values and new_values. No new version row is created, but goTo(), rewind(), and diff() still work as expected.

If an attribute should never appear in version history, use excludedFromVersioning() instead. amendCurrentVersion is for attributes you still want tracked, just not as a separate version.

Attach metadata

Record why a change was made:

Rewind::withMeta(['reason' => 'Bulk price update', 'ticket' => 'JIRA-123']);
$product->update(['price' => 29.99]);

Metadata is stored in the version's meta field and automatically cleared after version creation.

Event type tracking

Each version records the event that created it: created, updated, deleted, or restored.

$creates = $post->versions()->where('event_type', VersionEventType::Created->value)->get();

Initialize a v1 without changes

If you have an existing model and want to create a baseline version record:

$post->initVersion();

Working With Multiple Models

Batch versioning groups changes across models under a shared identifier:

$batchUuid = Rewind::batch(function () {
    $order->update(['status' => 'shipped']);
    $item->update(['shipped_at' => now()]);
});

// Query all versions in the batch
$versions = RewindVersion::inBatch($batchUuid)->get();

Managing Storage

Pruning old versions

# Keep the last 50 versions per model
php artisan rewind:prune --keep=50

# Delete versions older than a year
php artisan rewind:prune --days=365

# Combine both (--keep protects recent versions regardless of age)
php artisan rewind:prune --keep=50 --days=365

# Prune a specific model type
php artisan rewind:prune --keep=50 --model=App\\Models\\Post

# Dry run
php artisan rewind:prune --keep=50 --pretend

When versions are pruned, Rewind automatically converts the new oldest remaining version into a full snapshot so navigation continues to work.

Schedule it:

Schedule::command('rewind:prune --keep=50 --force')->daily();

You can set defaults for --keep and --days in config/rewind.php via prune_keep_versions and prune_older_than_days.

Automatic version limits

Cap versions per model:

class Post extends Model
{
    use Rewindable;

    protected static int $maxRewindVersions = 30;
}

Or set a global default via the max_versions config key. The per-model property takes precedence.

Configuration

Custom version model

Extend RewindVersion with your own model:

// config/rewind.php
'version_model' => App\Models\CustomRewindVersion::class,

Your model must extend AvocetShores\LaravelRewind\Models\RewindVersion.

Queued version creation

For high-write models, dispatch version creation to a queue:

// config/rewind.php
'listener_should_queue' => true,

Queue retry behavior is configurable via the queue config key.

Lock timeout handling

When a cache lock can't be acquired, behavior is configurable via on_lock_timeout:

  • log (default): Logs an error silently.
  • event: Dispatches a RewindVersionLockTimeout event for custom handling.
  • throw: Throws a LockTimeoutRewindException. Useful with queued listeners since it triggers Laravel's retry mechanism.

Snapshot interval

Controls how often full snapshots are stored vs. partial diffs. Default is every 10 versions. Higher values save storage at the cost of longer reconstruction times.

// config/rewind.php
'snapshot_interval' => 10,

How It Works

Rewind maintains a linear, non-destructive history. Here's what happens when you edit a model while on an older version:

  1. Create a post, then update it. You're at v2.
  2. Rewind to v1.
  3. Update the post again.

Rewind uses the previous head version (v2) as the old_values for the new version (v3), creates a full snapshot, and marks v3 as the new head:

[
    'version' => 3,
    'old_values' => [
        'title' => 'New Title', // From v2, not v1
    ],
    'new_values' => [
        'title' => 'Rewind is Awesome!',
    ],
]

The history always reads as if you updated from the previous head. You can jump around freely without losing data.

Testing

composer test

Changelog

Please see CHANGELOG for more information on what has changed recently.

Contributing

Please see CONTRIBUTING for details.

Security Vulnerabilities

Please review our security policy on how to report security vulnerabilities.

Credits

License

The MIT License (MIT). Please see License File for more information.