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

Revisionable Laravel Package

rhyanrandolph/revisionable

Laravel package that keeps a revision history for Eloquent models, automatically logging create, update, and delete changes with user context. Easily inspect past versions, compare field changes, and restore or revert records when needed.

View on GitHub
Deep Wiki
Context7

Getting Started

1. Install and publish

composer require rhyanrandolph/revisionable
php artisan vendor:publish --provider="RhyanRandolph\Revisionable\RevisionableServiceProvider"
# (namespace varies by fork — check composer.json / the provider in vendor/)
php artisan migrate

You get config/revisionable.php and a revisions migration with roughly: id, revisionable_type, revisionable_id, user_id, key, old_value, new_value, created_at, updated_at.

2. Where to look first

  • config/revisionable.php — the user model class used for attribution, sometimes the table name.
  • database/migrations/*_create_revisions_table.php — confirm indexes before you go live; add a composite index if it's missing.
  • vendor/rhyanrandolph/revisionable/src/RevisionableTrait.php — read bootRevisionableTrait() and the method that writes rows (often saveRevisions()). That one method explains 90% of the behaviour you'll debug later.
  • The shipped Revision model — you'll likely subclass it.

3. Attach the trait and take a first pass

use RhyanRandolph\Revisionable\RevisionableTrait;

class Article extends Model
{
    use RevisionableTrait;

    protected $keepRevisionOf = ['title', 'body', 'status'];
    protected $revisionCreationsEnabled = true;
    protected $historyLimit = 50;      // keep last 50 per field/model
    protected $revisionCleanup = true; // required for historyLimit to prune
}

4. First use case: the edit-history panel

$article->update(['title' => 'New title']);

foreach ($article->revisionHistory()->with('user')->latest('id')->get() as $rev) {
    // "Jane Doe changed Title from 'Old' to 'New title' on 12 Mar"
    $rev->userResponsible()?->name;
    $rev->fieldName();   // humanised 'title'
    $rev->oldValue();
    $rev->newValue();
}

If that loop renders correctly, the package is wired up. Everything below is refinement.

Implementation Patterns

Whitelist, don't blacklist. $dontKeepRevisionOf means every new column you add is audited by default — including last_login_at, views, and remember_token. Prefer $keepRevisionOf and treat it as part of the model's contract.

Render history as a timeline, not a table. One row per changed field is noisy; group by created_at (or by a batch/request id you add yourself) so a single save shows as one entry with a diff.

$history = $article->revisionHistory()
    ->with('user')
    ->latest('id')
    ->paginate(30)
    ->groupBy(fn ($r) => $r->created_at->toDateTimeString());

Global audit feed across models. The relation is a morph, so a single query backs an admin activity log:

Revision::with(['user', 'revisionable'])
    ->latest('id')
    ->paginate(50)
    ->through(fn ($rev) => [
        'who'    => $rev->userResponsible()?->name ?? 'system',
        'what'   => class_basename($rev->revisionable_type),
        'name'   => $rev->revisionable?->identifiableName(), // override this per model
        'field'  => $rev->fieldName(),
        'before' => $rev->oldValue(),
        'after'  => $rev->newValue(),
    ]);

Override identifiableName() on each audited model to return something meaningful ($this->title, $this->email) — the default is usually the primary key or class name, which reads badly in a feed.

Attribution outside HTTP. user_id normally comes from Auth::id(), which is null in queue workers, console commands, and seeders. Set it explicitly for those paths (the trait exposes a $revisionUserId property / setter in the forks) and always render userResponsible() as nullable. If you do a lot of queued work, put the actor id in the job payload and re-hydrate it before saving.

Suppress revisions for bulk operations. Imports, backfills, and seeders should not generate thousands of rows:

$article->disableRevisioning();
$article->update($imported);
$article->enableRevisioning();

Some forks ship a closure form (withoutRevisioning(fn () => ...)); if yours doesn't, a try/finally around the enable/disable pair is the safe pattern.

Retention is a save-time side effect. $historyLimit only prunes when $revisionCleanup = true, and it runs inside the save cycle — so a hot model pays an extra DELETE per write. For high-write tables, drop the built-in cleanup and prune nightly with your own query on created_at.

"Restore this version" is DIY. There are no snapshots; you reconstruct state by replaying new_value for each changed key up to a timestamp. Build it as a service that walks revisionHistory()->where('created_at', '<=', $ts)->oldest('id') and applies values in order — and be explicit in the UI that it's a reconstruction, not a stored snapshot.

Wrap related writes in a transaction. Revision rows are inserted in the model's saved event; if you save a parent and two children outside a transaction, you can get a history that doesn't match any real state. DB::transaction(fn () => ...) also makes the revision insert roll back with the model.

Add the index you need. The shipped migration usually has separate indexes on revisionable_type/revisionable_id. Audit screens query both plus a sort, so add:

$table->index(['revisionable_type', 'revisionable_id', 'id'], 'revisions_model_lookup');

Testing. Assert on the table directly rather than on rendered output:

$this->assertDatabaseHas('revisions', [
    'revisionable_id' => $article->id,
    'key'             => 'title',
    'old_value'       => 'Old',
    'new_value'       => 'New title',
]);

And add a negative test: Article::where(...)->update([...]) should produce zero revision rows — that documents the package's boundary for the next developer.

Gotchas and Tips

Only Eloquent save paths are audited. Model::where(...)->update(), insert(), upsert(), DB::statement(), and raw seeders bypass revisions entirely. This is the number one source of "why is this change missing from the history" tickets. If a code path must be audited, it must go through save()/update() on a hydrated model — or you write the revision yourself.

Values are stored as strings, so type is lost. old_value/new_value are text columns. false, 0, '', and null all blur together depending on driver and cast. Before/after comparisons in views will show "1" for a true boolean and "" for false. Either cast in your Revision subclass or render through the audited model's cast definitions.

JSON/array casts get serialised raw. A one-character change in a settings JSON column stores the entire blob twice. Consider excluding JSON columns from $keepRevisionOf and auditing the meaningful keys separately.

revisionHistory is a normal Eloquent relation and gets cached. After $article->update(...), $article->revisionHistory on the same instance may return the pre-save collection. Use $article->revisionHistory()->get(), unsetRelation('revisionHistory'), or refresh().

Ordering isn't guaranteed. revisionHistory has no default orderBy in most versions. Always add ->latest('id') or ->oldest('id') — otherwise pagination drifts and "before/after" reads backwards on fast saves.

Morph map / class renames break old rows. If you introduce Relation::enforceMorphMap(), historical rows still contain the FQCN, so revisionable resolves to null for old data. Same problem if you rename or move a model. Plan a data migration, or set the morph map before you start writing revisions.

user_id has no FK by default. Deleting a user leaves orphan revisions; userResponsible() returns null and your timeline shows a blank actor. Use nullOnDelete on the FK, or snapshot the actor's name into the revision.

Secrets leak easily. Passwords, API tokens, and 2FA secrets are ordinary columns. They will be written to revisions the first time you forget to whitelist. Do an explicit $keepRevisionOf audit before your first deploy, and consider a model-level $hidden-style guard or a boot-time exception if a sensitive key is present.

$revisionCreationsEnabled is noisy. On create it writes a row per field with old_value = null. Useful for "created with X" displays; wasteful if you only care about edits and you have 40 columns.

Soft deletes need configuring. The revisions of a soft-deleted model don't automatically disappear or reappear. If you want the history to follow the model's lifecycle, subclass Revision with SoftDeletes and call softDeleteRevisions() on the parent's deleting event — decide this before you have production data.

Config caching. After publishing, php artisan config:clear — a cached stale revisionable.user pointing at App\User is a classic "userResponsible() always null" cause.

Debugging a missing revision. Check in this order: (1) was the attribute actually dirty? ($model->getDirty() — assigning the same value writes nothing), (2) is the key in $keepRevisionOf / not in $dontKeepRevisionOf?, (3) is revisioning disabled from a previous bulk run? (disableRevisioning() without the matching enable is easy to leave behind on a shared instance), (4) did the write go through a query builder instead of the model?

Maintenance risk is real. This is a fork of a fork with a 2023 last release and effectively zero dependents — the score reflects that. Pin the exact version, verify it against your Laravel major before upgrading the framework, and expect to vendor/patch the trait rather than get upstream fixes. Because the logic is one trait plus one model, the escape hatch is genuinely cheap: copy both into app/, keep the table and column names, and you own the audit layer without a disruptive migration. Do that before you build UI on top of it.

Extension points worth using over forking: identifiableName() and fieldName() (override per model for display), userResponsible() (override for non-standard auth or multi-guard), and a config-bound Revision subclass where you can add casts, scopes (scopeForModel, scopeByUser), and accessors for oldValue()/newValue(). Those four cover almost every customisation request without touching vendor code.

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.
coleus/frame
thtg88/journalism
filagora/filagora-ai-forums
rlacerda83/query-parser
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings