Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Rewind Laravel Package

avocet-shores/laravel-rewind

Laravel Rewind gives your Eloquent models full version control: rewind, fast-forward, restore, diff, and query point-in-time state. It stores partial diffs with periodic snapshots for efficient storage and fast reconstruction, plus thread-safe locking, queued versioning, and automatic pruning.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and wire it up in five minutes

composer require avocet-shores/laravel-rewind
php artisan vendor:publish --provider="AvocetShores\LaravelRewind\LaravelRewindServiceProvider"
php artisan migrate

Then, for each model that needs history:

use AvocetShores\LaravelRewind\Traits\Rewindable;

class Post extends Model
{
    use Rewindable;
}

Each rewindable table needs a current_version column. Generate the migration with php artisan rewind:add-version and run php artisan migrate. Models are tracked automatically after that — no observer registration, no manual ->saveVersion() calls.

Where to look first

  1. config/rewind.php — this drives almost every operational decision: snapshot_interval, listener_should_queue, on_lock_timeout, version_model, max_versions, and prune defaults. Read it before writing code, not after something breaks.
  2. AvocetShores\LaravelRewind\Models\RewindVersion — the version row is the primitive. Understanding its columns (old_values, new_values, event_type, meta, batch_uuid, version) makes the facade methods predictable.
  3. The Rewind facade — rewind, fastForward, goTo, restore, diff, replay, versionAt, getVersionAttributes, cloneModel, batch, withMeta, amendCurrentVersion.

First use case

The natural first win is an internal CMS audit trail. Update a post, then inspect:

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

$post->versions;                       // history rows, newest first
Rewind::diff($post, 1, 2)->changed;    // ['title' => ['old' => 'Draft', 'new' => 'Published Title']]

If you have existing production rows, call $post->initVersion() on them (or in a backfill command) so there is a baseline version to diff and restore against. Without it, diff($post, 1, ...) has nothing to anchor to.

Finally, confirm your cache store supports atomic locks in every environment where writes happen. Rewind uses cache locks to keep version sequences consistent, and the array store only locks within a single process — fine for tests, not for production concurrency.

Implementation Patterns

Audit trail with intent (withMeta)

Wrap meaningful writes so the reason is queryable later:

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

Meta is stored on the created version and cleared automatically after version creation. Keep the withMeta call immediately adjacent to the write it describes; don't set it at the top of a long request and hope the right write consumes it.

Preview vs. commit in admin UIs

This distinction is the most important pattern in the package:

  • Rewind::goTo($post, 3) mutates the live model and moves the pointer without writing a version. Good for navigation and intentional "move me here" actions.
  • Rewind::restore($post, 3) creates a new version with v3's attributes, records event_type = restored and meta['restored_from_version'] = 3. Good for compliance and anything that must be auditable.

For a read-only "what did this look like?" preview, don't call goTo on the real record. Use Rewind::getVersionAttributes($post, 3) to render the state, or Rewind::cloneModel($post, 3) if you need an actual model instance for form binding. Only mutate the live record when the user confirms.

State machine tracking for orders and payments

Declare the state-bearing fields explicitly:

protected array $rewindStateFields = ['status', 'payment_status'];

Everything else is still versioned normally; these fields additionally get queryable transitions:

$order->versions()->whereStateTransition('status', 'pending', 'shipped')->get();
$order->versions()->whereStateBecame('status', 'shipped')->byUser($userId)->get();

$order->stateHistory('status');
// [{version, from, to, created_at}, ...]

stateHistory() is the right input for a status timeline component — it's already shaped for display, and whereStateTransition accepts null wildcards for either direction.

Replay instead of per-version reconstruction

If you need the full state at every version in a range (activity feed, export, migration), use replay. It reconstructs incrementally, so it's O(range) rather than O(range × depth), and callback return values are collected into a collection:

$titles = Rewind::replay($post, 1, 10, fn ($version, $attributes) => $attributes['title']);

It works backward too (replay($post, 10, 1)), which is useful for "walk me back to the first draft" flows.

Batch versioning across aggregate writes

When an order and its items change together, group them:

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

RewindVersion::inBatch($batchUuid)->get();

This gives you a single logical revision to present in the UI ("Shipped on …") rather than five unrelated history entries. Combine with byUser for a per-actor activity view.

Retention policy as code

Use both mechanisms deliberately:

  • protected static int $maxRewindVersions = 30; for per-model caps (takes precedence over config).
  • Schedule::command('rewind:prune --keep=50 --force')->daily(); for the global sweep, with --pretend and --model=App\\Models\\Post for one-off investigations.

Pruning converts the new oldest remaining version into a full snapshot, so navigation keeps working after old rows disappear — but your effective "how far back can I go" answer is now governed by --keep and --days, not by what users assume.

High-write models

For models that change constantly, set listener_should_queue => true and use on_lock_timeout => 'throw' so queued jobs retry through Laravel's queue retry mechanism rather than silently logging. Then make sure your queue workers and the web process share the same cache store — otherwise the lock is decorative.

Custom version model

If you need relations on the version row ($version->user, $version->comment), point version_model at a subclass of RewindVersion. Keep it a subclass; the package relies on the base contract.

Gotchas and Tips

Query-builder writes bypass versioning. Post::where('status', 'draft')->update([...]) and DB::table(...)->update(...) do not fire Eloquent model events. Bulk administrative actions are exactly where audit trails are most expected, so route them through model instances (or chunk and save() each) if they must be versioned.

goTo is not a read operation. It writes to the model and moves current_version. Calling it on a model bound to a live record inside a "preview" endpoint will change your production data. Reach for getVersionAttributes or cloneModel for anything read-only.

meta is ambient state. It's cleared after version creation. Nested writes inside a withMeta block — a related model save, an event listener that updates something versioned — can consume the metadata on the wrong version. Keep the scope tight.

amendCurrentVersion collapses history, including transitions. Folding multiple state changes into one amend means the transition collapses from the original from to the final to. That's documented behavior, but it's easy to be surprised by a missing intermediate state when reconciling an order timeline. If an attribute should never appear in history, use excludedFromVersioning() instead; amend is for things you still want tracked, just not as a separate revision.

Locks need a real store. Redis, database, or memcached — not array. In multi-server deployments, a misconfigured cache store is the most likely cause of duplicate or interleaved version numbers. Pick on_lock_timeout before launch: log hides problems, event lets you alert, throw retries.

Queued versioning is eventually consistent. With listener_should_queue, a request can return before the version row exists. Don't read $post->versions in the same request and assume it's current, and don't build UI that depends on synchronous history when the queue is enabled.

Existing rows need a baseline. initVersion() is not automatic for pre-existing data. Backfill it, or your first diff and first restore will target a version that doesn't meaningfully exist.

snapshot_interval is a direct storage/speed dial. Default 10. Raising it saves space but lengthens reconstruction for versions far from a snapshot; lowering it speeds reads and grows the table. If you prune aggressively, the converted oldest version becomes a snapshot, which partially offsets the cost — but measure before tuning.

Diff direction matters. diff($post, 5, 1) swaps old and new relative to diff($post, 1, 5). If you're rendering a "changes in this version" panel, the order should be previous → target, and previous is not always version - 1 after restores.

byUser depends on attribution being captured. Confirm your authenticated-user context, queue jobs, and console commands actually populate the user on the version row. A scheduled job that rewrites prices will show up as unattributed history unless you set context explicitly.

Soft deletes and deleted event type. Verify in your own tests how delete() and restore() interact with the version chain on models using SoftDeletes; don't assume the audit trail reads the way you expect just because a deleted version exists.

Adoption risk. This package has a small install base and zero known dependents. Pin an exact version, read the published migrations rather than trusting them blind, and write tests around concurrency (two workers updating one model) and around your pruning schedule before enabling it on high-value tables. The README and changelog are good; the surrounding ecosystem is thin, so your test suite is the real safety net.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
mydnic/volet-feature-board-filament-plugin
wmde/email-address
refinery29/test-util
dees040/laravel-api-responses
teamzac/geocoder
ideatica/config-writer
keaton-php/pipe-dream
jacksonwebservices/laravel-user-suite
maksimru/conditional-laravel-debugbar
gismatul/nova-support-chat
gismatul/laravel-support-chat
networkrailbusinesssystems/oracle-api
alphabalex/messaging-made-easy
rawphp/warp
shootkiran/merobug
narakode/fineauth
numencode/wn-syncops-plugin
ibnnajjaar/mib-global-pay
refinery29/php-cs-fixer-config
parsnick/steak