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.
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.
$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.
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.
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.
getModelMeta for richer snapshotsDefault 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.
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).
withoutEvents() for importsBulk 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.
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.
How can I help you explore Laravel packages today?