overtrue/laravel-versionable
Laravel Versionable is a minimalist package that adds version history to your Eloquent models. Track only changed attributes, keep a set number of versions, whitelist or blacklist fields, and easily revert to any specified version.
Check your version constraints before installing. The README advertises PHP >= 8.1 / Laravel >= 9, but that reflects the 5.x line. v6.0.0 added Laravel 13 support and bumped the floor to PHP 8.3; v5.5.0 is the Laravel 12 line. On a Laravel 10/11 app, pin ^5.5 so you don't get dragged into a PHP bump by a routine composer update.
composer require overtrue/laravel-versionable -vvv
php artisan vendor:publish --provider="Overtrue\LaravelVersionable\ServiceProvider"
php artisan migrate
The publish step gives you two things worth reading immediately, in this order:
versions table (versionable_id, versionable_type, contents JSON, an incrementing version integer, timestamps, and a deleted_at column because versions are soft-deletable). Every behaviour you'll debug later flows from this shape.config/versionable.php — holds the default version model and retention settings. The README's "keep the specified number of versions" feature lives here, so confirm what the default is before you assume history is unbounded.Then make a model versionable:
use Overtrue\LaravelVersionable\Versionable;
class Post extends Model
{
use Versionable;
protected $versionable = ['title', 'content'];
}
First use case: an audit trail with a revert button. A CMS Post is the canonical fit — editors change copy, someone needs to see what it looked like last Tuesday and roll back. Save a couple of updates, then confirm the moving parts in php artisan tinker:
$post->versions; // all versions, oldest → newest
$post->latestVersion; // lastVersion works too
$post->versionAt('2025-01-06 12:00:00');
$post->getVersion(1)->revert(); // or $post->revertToVersion(1)
Once those four lines behave, you understand 80% of the package. Everything else is strategy choice, pruning, and event plumbing.
Pick whitelist or blacklist deliberately, per model. $versionable (whitelist) and $dontVersionable (blacklist) are mutually exclusive in spirit — use the whitelist for models with a clear "editorial content" surface (title, body, status, published_at) and the blacklist for models where almost everything matters except bookkeeping columns (id, created_at, updated_at, deleted_at, counters). Blacklist is the lower-maintenance choice: new columns are versioned by default instead of silently ignored.
Choose the storage strategy by what you need to display, not what you need to store.
protected $versionStrategy = \Overtrue\LaravelVersionable\VersionStrategy::DIFF; // default
protected $versionStrategy = \Overtrue\LaravelVersionable\VersionStrategy::SNAPSHOT; // full attribute set
DIFF stores only the attributes that changed — small rows, but every version is a patch, so a version means nothing without its predecessors. SNAPSHOT stores the full versionable set each time — bigger rows, but each version is self-describing, which is what you want if you're rendering a "restore this state" UI or diffing arbitrary version pairs. On text-heavy models (markdown bodies, HTML), snapshot mode multiplies your largest column by every save; measure before committing.
Typical admin workflow: list → diff → revert. List $post->versions newest-first with created_at and version number. For a two-version comparison:
$diff = $post->getVersion(2)->diff($post->getVersion(5));
$diff->toArray(); // ['title' => ['old' => 'A', 'new' => 'B'], ...]
$diff->toSideBySideHtml(); // good for an admin panel
$diff->toText(); // good for emails, exports, logs
The underlying renderer is jfcherng/php-diff, so you can pass its differ and render options through as the first two arguments, and true as the third to strip HTML tags — useful when content fields contain markup and you want clean text diffs. Version::isLatest() (5.3.0+) saves you comparing against latestVersion manually when you need to badge the current row or disable the revert button.
Revert creates a new version. revert() saves the model, which fires the saved hook, which writes another version. That's usually what you want for an audit trail (the rollback is itself auditable) — just don't be surprised when your version count grows. When you need to preview a revert without committing, use $version->revertWithoutSaving(), which returns an unsaved model you can render in a form.
Wrap anything bulk in withoutVersion(). Seeders, imports, data-fix migrations, and queue backfills will otherwise generate one version row per record and bloat the table (and, with retention pruning on, evict the real history you actually care about).
Post::withoutVersion(function () {
Post::factory()->count(5000)->create();
});
v6.0.1 hardened this so the previous versioning state is restored even when the callback throws, including nested calls. If you're on 5.x and relying on withoutVersion around fallible code, upgrade — a thrown exception in the closure could otherwise leave versioning disabled for the rest of the request.
Extend the Version model when you need authorship and intent. The package records what and when, not who or why. Subclass it, add user_id/reason/note via your own migration on the versions table, and point the model at it:
class PostVersion extends \Overtrue\LaravelVersionable\Version {}
class Post extends Model
{
use Versionable;
public string $versionModel = PostVersion::class;
}
This is the main extension point in the package — the trait itself is small, but the version model is where you customise relations, casts, and presentation. Fill the extra columns from a creating listener on your subclass or an observer on the parent.
Housekeeping is your job. removeVersion(), removeVersions([...]), and removeAllVersions() soft-delete; the forceRemove* variants don't; restoreTrashedVersion($id) brings a soft-deleted row back. A nightly command that prunes versions beyond your retention window (per model, based on $post->versions()->latest('id')->skip(N)->pluck('id')) keeps the table from becoming the largest one in your database. The config retention setting is worth checking, but don't assume it covers every model — read it and verify with a test.
Filament shops can skip building the UI: mansoorkhan96/filament-versionable wraps this package with a revisions relation manager. It's listed in the README's Integrations section and is the reason many teams adopt this package over hand-rolled audit tables.
Testing. Add RefreshDatabase, create a model, update twice, assert $post->versions()->count() === 2 (initial create plus one update), assert the diff shape, assert a revert restores values. Remember factories run inside your test's transaction, so wrap factory-heavy setup in withoutVersion() unless the versions are the subject of the test.
$version->contents holds only the changed attributes. Reverting applies only those attributes — it does not reconstruct the full historical row. If "restore exactly what the row looked like" is a requirement, use SNAPSHOT.save(). The blacklist path had a bug here fixed in 5.1.2 — if you're on an older 5.x, upgrade.Post::where(...)->update([...]), increment(), DB::table(...)->update(), and saveQuietly()/updateQuietly() fire no model events, so no versions. Any "silent" write path needs an explicit follow-up if you want it in history.revert() goes through normal saves, so observers, mutators, validation, and updated_at all run. Casts are applied when attributes are rehydrated; if a version was written before you changed a cast (say status from string to enum), reverting an old version can throw. Cast changes to versioned attributes need a migration plan, not just a code change.contents JSON column on write. A save that fails to encode (invalid UTF-8 from a scrape, an unserialisable value, a resource) can throw during the saved event and take down the parent save with it. Sanitise user-pasted content; 5.2.6 added pretty-printing for JSON columns, which is cosmetic but handy when eyeballing rows in a DB client.versionable_type rows in the same deploy — or use a morph map and accept the mapping constraints. Plan refactors accordingly.versionAt() filters on the version's created_at, not the parent's timeline, and compares against your app timezone. Pass a Carbon instance in the app timezone and test around daylight-saving boundaries if the audit trail is ever used for compliance.versionable_id/versionable_type and don't over-engineer.id/created_at instead.$post->versions->count() per row is an N+1 on the largest table you own. Eager load with withCount('versions') or query the versions table directly with aggregates.removeVersion() keeps the row recoverable via restoreTrashedVersion(), which is what you want when an editor accidentally prunes a version. Reserve forceRemove* for scheduled cleanup jobs, and gate it behind an explicit command.agents/AGENTS.md if you plan to contribute or vendor a patch. The maintainer's guidance is minimal, high-confidence diffs: no public API changes without an issue demanding it, no new dependencies, and check composer scripts for the real test/lint commands before running raw PHPUnit.How can I help you explore Laravel packages today?