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

History Laravel Package

panoscape/history

Eloquent model history tracking for Laravel. Add the HasOperations and HasHistories traits to record who changed which model, then browse a timeline of operations via the History facade. Includes migrations, config, and translations.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and publish.

composer require panoscape/history
php artisan vendor:publish --provider="Panoscape\History\HistoryServiceProvider" --tag=migrations
php artisan vendor:publish --provider="Panoscape\History\HistoryServiceProvider" --tag=config
php artisan vendor:publish --provider="Panoscape\History\HistoryServiceProvider" --tag=translations
php artisan migrate

Laravel 6+ needs no service provider registration (auto-discovery). Only 5.6.x requires the manual HistoryServiceProvider + History alias entries in config/app.php.

Wire up exactly two traits. This is the part that trips people up on day one — the package splits the concern in half:

// The actor: whoever performs the operation
class User extends Authenticatable
{
    use HasOperations;
}

// The subject: whatever gets tracked
class Article extends Model
{
    use HasHistories;

    public function getModelLabel(): string
    {
        return $this->title;
    }
}

HasHistories declares an abstract getModelLabel(). The model will fatal-error the moment a tracked event fires if you forget it. This value is the "what" in Who did what — it should be a human-readable identifier ($this->name, $this->invoice_number, $this->title), not the primary key.

First use case — a per-record audit panel. The obvious first win is dropping a history timeline on a detail page:

// In the controller
$article = Article::findOrFail($id);

// In the view
@foreach ($article->histories()->latest('performed_at')->take(10)->get() as $history)
    <li>
        {{ $history->message }}
        <small>{{ $history->performed_at->diffForHumans() }}</small>
    </li>
@endforeach

Out of the box you get created, updating, deleting, and restored recorded automatically, with updating capturing a field-level diff in $history->meta. That's the whole package in one screen — the rest of this assessment is about making that reliable and presentable in a real app.

Where to look first: the published config/history.php. Almost every production complaint (histories missing in queues, anonymous users, passwords leaking into diffs) is solved by a config key, not code.

Implementation Patterns

Render the message and the diff together

$history->message is a pre-rendered string stored at write time — good for a feed, useless for detail. Pair it with meta for an expandable "3 fields changed" row:

@foreach ($article->histories as $history)
    <div class="history-row">
        <p>{{ $history->message }}</p>

        @if ($history->hasUser())
            <span class="actor">{{ $history->user->name }}</span>
        @endif

        @if ($meta = $history->meta)
            <table>
                @foreach ($meta as $change)
                    <tr>
                        <td>{{ $change['key'] }}</td>
                        <td>{{ $change['old'] }}</td>
                        <td>{{ $change['new'] }}</td>
                    </tr>
                @endforeach
            </table>
        @endif
    </div>
@endforeach

meta is null for non-update events by default, so always guard on it. hasUser() exists because unauthenticated operations (seeder, console, queue) produce records with no user — $history->user will be null.

Treat the relationships as query builders

Both histories() and operations() return Eloquent relations, so all the usual chaining works. The two highest-value patterns:

// Eager load an activity feed
$feed = History::with(['model', 'user'])
    ->orderByDesc('performed_at')
    ->paginate(50);

// Per-user scoped feed
$user->operations()
    ->where('created_at', '>=', now()->subDays(7))
    ->orderByDesc('performed_at')
    ->get();

Prefer orderBy('performed_at') over created_at — that's the column the package populates and indexes conceptually. (performed_at is filled via now() since v2.6.1; older data used time() and may be timezone-offset, which is worth knowing if you're upgrading an existing table.)

For a detail page, always eager load the actor:

$article->histories()->with('user')->latest('performed_at')->paginate(20);

Without with('user') a 20-row timeline is 20 extra queries.

Record domain events the schema doesn't see

The four built-in events only cover row mutations. The real day-to-day value comes from ModelChanged for semantic events — status transitions, permission changes, exports, approvals:

use Panoscape\History\Events\ModelChanged;

// In a service or controller
$user->roles()->sync($request->roles);
event(new ModelChanged(
    $user,                                        // subject model
    'User roles updated',                         // message
    ['roles' => $user->roles()->pluck('id')->toArray()], // optional meta
));

The first argument is the subject (the model the history is attached to), not the actor — the actor is resolved from the auth guard by the listener. This is the single most common misreading of the API. Fire it inside the same transaction as the mutation; the listener inserts synchronously, so a rollback discards the history too, which is usually what you want.

Customize getModelMeta for richer snapshots

Default meta is a key/old/new diff for updating only. For created/deleted you often want the full state captured so the record is reconstructable later:

class Article extends Model
{
    use HasHistories {
        getModelMeta as protected traitGetModelMeta;
    }

    public function getModelLabel()
    {
        return $this->getOriginal('title', $this->title);
    }

    public function getModelMeta($event)
    {
        if ($event === 'updating') {
            return $this->traitGetModelMeta($event);
        }

        return $this->only(['title', 'status', 'author_id']);
    }
}

Returning a plain array is safer than returning $this — the trait serializes it, and returning the model pulls in every attribute including hidden/relationship noise depending on how the cast behaves. Be explicit about keys.

Localize the message, not the UI

The message is rendered and stored at write time. That means translations are frozen per record — an article created under en stays English even if the user later switches to ja. If you need multi-locale audit feeds, don't rely on translation for the stored message; store a structured event_key yourself (via the fourth ModelChanged argument) and render at read time.

For single-locale apps, the published lang/*/history.php is clean:

'created'  => ':model :label を作成',
'updating' => ':model :label を更新',
'models'   => [
    'article' => '記事',       // Article => 記事
    'invoice' => '請求書',
],

Model keys are the class base name in snake case (ComponentTemplate → component_template).

Pair it with withoutEvents() for imports

Bulk imports, seeders, and backfills will otherwise generate hundreds of thousands of rows. Wrap them:

Article::withoutEvents(function () use ($rows) {
    foreach ($rows as $row) {
        Article::create($row);
    }
});

This applies to all Eloquent events, not just this package — so make sure no other observers depend on created during that block.

Gotchas and Tips

Silent no-ops in console and tests. This is the #1 support question. Two config defaults mean tracking is off in exactly the environments where you write your assertions:

'console_enabled' => false, // artisan, seeders, queue workers
'test_enabled'    => false, // phpunit/pest runs

Consequences: a job dispatched to a queue worker records nothing, and a feature test asserting $article->histories()->count() fails for reasons unrelated to your code. Override in phpunit.xml:

<env name="HISTORY_TEST_ENABLED" value="true"/>

(or set test_enabled => true in config/history.php and remember config caching). For queue-based writes, set console_enabled => true — but understand you're now recording every seeder run in production too, so combine it with env_blacklist.

The "new label" bug on update. Documented in the README as a known issue and it will bite you the first time you edit a title:

// Original title 'my title', changed to 'new title'
// Expected: "Updating Article my title"
// Actual:   "Updating Article new title"

Because the updating listener runs after attributes are set. The fix is one line:

public function getModelLabel()
{
    return $this->getOriginal('title', $this->title);
}

Apply this defensively on every tracked model whose label is mutable, not just the ones where you've noticed it.

Non-default auth guards produce anonymous histories. If your app authenticates via api, admin, or a Sanctum guard, every record lands with a null user and hasUser() returns false. Enable guard scanning:

'auth_guards' => ['web', 'admin', 'api'], // ordered; first hit wins
// or `true` for full scan — but guard priority is undefined

Prefer the explicit array. Order matters and full-scan mode does not guarantee which guard is picked when a user is resolvable from more than one.

Bulk query-builder updates bypass everything.

Article::where('status', 'draft')->update(['status' => 'published']); // no history
Article::find(1)->update(['status' => 'published']);                  // history ✓

Eloquent events only fire on model instances. Any raw update(), insert(), or DB::table() write is invisible to the package. If your app has admin bulk actions, audit requirements break silently there — either loop the models or emit ModelChanged manually.

Blacklist before you leak secrets.

'attributes_blacklist' => [
    \App\User::class => ['password', 'remember_token', 'two_factor_secret'],
],

Without this, a password change produces a meta entry containing the old and new bcrypt hashes, visible in whatever UI you render. Audit this list whenever you add an encrypted or sensitive column. Similarly, user_blacklist accepts class names, morph map aliases, and the literal 'nobody' to suppress unauthenticated writes:

'user_blacklist' => ['nobody', 'admin'], // suppress system + admin noise

Morph maps work as of v2.9.0, but only after you upgrade. If your model_type/user_type columns already contain FQCNs and you later introduce Relation::enforceMorphMap(), historical rows won't resolve through $history->model(). Don't rename morph aliases on a table with existing history.

Histories outlive their models. The relation is polymorphic with no cascading FK, so deleting an Article leaves its histories intact. That's a feature for audit trails — but $history->model() returns null afterwards and $history->message is your only record of what it was. Always render from message/meta and treat model() as a convenience link, not the source of truth.

No pruning command ships with it. The histories table grows monotonically with every save. On a busy model that's one extra INSERT per write plus unbounded storage. Add your own scheduled cleanup and index appropriately:

// App\Console\Commands\PruneHistories
History::where('performed_at', '<', now()->subMonths(18))->delete();

Index (model_type, model_id, performed_at) for detail timelines and (user_type, user_id, performed_at) for user feeds. Without these, a detail page on a heavily edited record does a full scan.

Test your tracking explicitly. A small helper pattern keeps it honest:

public function test_update_is_tracked()
{
    $this->actingAs(User::factory()->create());
    $article = Article::factory()->create(['title' => 'Old']);

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

    $history = $article->histories()->latest('performed_at')->first();
    $this->assertTrue($history->hasUser());
    $this->assertSame('title', $history->meta[0]['key']);
    $this->assertSame('Old', $history->meta[0]['old']);
}

Overall assessment. This is a small, sharp, single-purpose package — 163 stars, one maintainer, but consistently updated through Laravel 13 and with a real test suite behind the badges. It is not a full audit framework: no field-level history versioning, no restore-from-history, no pruning, no built-in diff UI. What it does provide — automatic per-model timelines with a stored human message and an update diff — it provides in about ten minutes of setup. Reach for it when you want an activity feed next to Article#show. Reach for something heavier (or a manual event-sourced table) if you need compliance-grade immutability, historical state reconstruction, or high-volume write paths where an extra synchronous INSERT per save is unacceptable.

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.
ably/pubsub-server
mstfkhazaal/defender
mfonte/base62x
karnoweb/metapilot
tobya/bcsapiwrapper
swalker2/cpanel
vish4395/laravel-translation-manager
morningtrain/economic
teksite/systeminfo-laravel
atomcms/voting
run6/weather
siokas/laravelembeddirectives
adamlien/pkuuids
gilsonsouza/lara-doc-generator
manelgavalda/enrollment_mobile
unabomber/timezone
delatbabel/contacts
astritzeqiri/g-recaptcha
kevinongko/laravel-genode
developeruz/laravel-analytics