noin/filament-activity-log
Filament Activity Log brings a clean, searchable activity timeline into your Filament admin panel, so you can browse, filter and inspect model changes recorded by spatie/laravel-activitylog without leaving the dashboard.
1. Install the foundation first. This package is a UI + ergonomics layer over spatie/laravel-activitylog. The activity table, Activity model, and LogsActivity trait all come from Spatie, so get that working before you touch the Filament side:
composer require spatie/laravel-activitylog
php artisan vendor:publish --provider="Spatie\Activitylog\ActivitylogServiceProvider" --tag="activitylog-migrations"
php artisan migrate
2. Add the Filament package.
composer require noin/filament-activity-log
3. Register the plugin on the panel that should show the log page (not necessarily every panel — see Gotchas on guards):
use Noin\FilamentActivityLog\ActivityLogPlugin;
public function panel(Panel $panel): Panel
{
return $panel
->id('admin')
->plugin(ActivityLogPlugin::make());
}
Namespaces/class names above reflect the package's public surface; because this is a small (2-star, single-maintainer) package with a recent release date, confirm exact names against the README and
src/before wiring into a production panel. Budget 15 minutes readingsrc/— it's tiny.
Where to look first, in order:
README.md — plugin registration + whether a Resource or a Page is published.src/ — the plugin class and the Log viewer (Page/Resource). This tells you whether the viewer is a Filament Resource (routable, /{id} detail pages) or a single Page (list-only).config/ — for log_name, causer resolution, and which models get listed in the filter dropdown.vendor/spatie/laravel-activitylog/config/activitylog.php — the actual behavior knobs live here, not in the Filament package.First use case — the 20-minute win: log a single Eloquent model and see it appear in the panel.
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity;
class Post extends Model
{
use LogsActivity;
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logFillable()
->logOnlyDirty()
->dontSubmitEmptyLogs();
}
}
Then create/update/delete a Post in the panel and open the activity page. If you see a row with old/new attribute JSON, the integration is working and everything else is polish.
Trait on the model, not the Resource. Resist the urge to log inside Filament actions. Model-level logging captures writes from seeders, imports, queues, tinker, and other panels — the panel viewer is just a read surface over that data. Logging in EditAction closures gives you a log that lies the moment someone uses artisan tinker.
Pick your attribute strategy deliberately. Three common shapes:
// A. Everything, dirty only — good for audit/compliance, noisy for big tables
LogOptions::defaults()->logAll()->logOnlyDirty()->dontSubmitEmptyLogs();
// B. Fillable only — the pragmatic default
LogOptions::defaults()->logFillable()->logOnlyDirty()->dontSubmitEmptyLogs();
// C. Explicit allow/deny — best when one column churns (e.g. updated_at, sort_order)
LogOptions::defaults()
->logOnly(['title', 'status', 'published_at'])
->logOnlyDirty()
->dontSubmitEmptyLogs();
For any model with a sort_order, drag-reorder column, or last_seen_at, use C. Otherwise a single drag-and-drop reorder produces N activity rows and buries the meaningful entries.
Enrich entries with tapActivity so the viewer is actually readable to non-developers. The description column is what people scan; the JSON is what they click into.
public function tapActivity(Activity $activity, string $eventName): void
{
$activity->properties = $activity->properties->merge([
'tenant_id' => Filament::getTenant()?->id,
'panel' => Filament::getCurrentPanel()?->getId(),
]);
}
Storing the panel ID is the cheapest multi-panel guard: one activity_log table, filterable per panel, no schema forks.
Log non-Eloquent events explicitly. The trait only covers model lifecycle. For "exported report", "impersonated user", "changed settings" use the fluent API, and always set performedOn so the viewer's subject link works:
activity('admin')
->performedOn($user)
->causedBy(auth()->user())
->withProperties(['ip' => request()->ip()])
->event('impersonated')
->log('Started impersonating user');
Soft deletes and restore. LogsActivity emits deleted and restored automatically when the model uses SoftDeletes — that's the restore support the package advertises. The gotcha is force deletes and deleted_at appearing in the attribute diff; if you use logOnlyDirty, restored entries are usually clean, but confirm by toggling a record before you trust the view.
Custom descriptions beat raw attributes. Override the event label so the viewer reads like a sentence instead of a diff:
public function getDescriptionForEvent(string $eventName): string
{
return "Post \"{$this->title}\" was {$eventName}";
}
Batch / noisy contexts. Wrap imports and mass updates with activity()->withoutLogs(fn () => ...) or Model::withoutLogging(...) when you're migrating data. Otherwise a 10k-row import creates 10k activity rows that you'll be pruning for months.
Extend the viewer rather than forking it. Because it's a Filament Resource/Page under the vendor path, the extension points are: publish the views, register your own ActivityLogResource subclass, or add a table filter via the resource's table() method (if exposed). For anything beyond cosmetics — custom filters, export, retention UI — the honest move is to copy the page into app/Filament/ and own it. A 2-star package is not a stable extension surface.
Causer guard mismatch is the #1 support ticket for every Filament activity-log integration. Spatie's default guard is web, but Filament admins authenticate on your panel guard. Result: every row has causer_id = null and the log says "System" for everything. Fix in config/activitylog.php:
'default_auth_guard' => null, // or explicitly 'admin'
null lets Spatie fall back to Auth::guard() defaults, which is usually right in a single-panel app. In multi-guard apps set it explicitly and remember to re-check after adding a second panel.
Migration conflicts. If activity_log already exists (Spatie already installed elsewhere, or a prior package), the Filament package's publish step may collide. Publish only the config if the migration already ran; don't blindly vendor:publish --all into an existing app.
logOnlyDirty() without dontSubmitEmptyLogs() is a footgun. A no-op save ($model->save() with no changes) produces an activity row with an empty properties diff. Combined with a Resource that saves on every modal open (form save() on unchanged data), you get phantom audit entries — which destroys trust in the log faster than having no log at all.
Schema growth. activity_log is unbounded by design. Before go-live add an index on (subject_type, subject_id) and (causer_type, causer_id) — Spatie's migration includes these, but verify — and decide a retention policy now. A prune command from day one beats a 3GB table in month eight. Also consider properties size: logAll() on a model with a large JSON column will store full snapshots twice per update.
logAll() on models with secrets. Password hashes, API tokens, remember tokens. Spatie does not redact; the viewer will render them in the properties JSON. Either exclude explicitly (logExcept(['password', 'remember_token', 'api_token'])) or mark the columns $hidden and exclude — $hidden does not influence the log.
The viewer's model filter list. Packages of this shape usually build a subject-type dropdown from models that have activity records, or from a config array. If the dropdown is empty or stale, that's a config/registration issue, not a bug in the log — check the config for the model map.
Authorization. A log viewer is a read of your entire change history — arguably more sensitive than the models themselves. Gate it (ActivityLogResource::canViewAny() or the equivalent) and don't ship it to a panel where User role can reach it by default. Also: if you logged with dontLogIfAttributesChangedOnly or excluded fields, the viewer won't show them — don't let anyone believe the log is a complete record.
Testing. There's no Activity::fake() in Spatie. Assert against the table:
$this->assertDatabaseHas('activity_log', [
'subject_type' => Post::class,
'subject_id' => $post->id,
'event' => 'updated',
'log_name' => 'default',
]);
For the Filament side, use Livewire component tests against the page's table — and test the auth guard path explicitly, since a passing actingAs($user) on the default guard will mask the causer bug that breaks production.
Version pinning. With a July 2026 release and 2 stars, pin the minor version ("noin/filament-activity-log": "~1.0") and be prepared for breaking changes on Filament major bumps — the panel plugin APIs (Plugin, Panel::plugin()) have shifted across Filament 3→4. Test the upgrade in a branch, not in the lockfile on a Friday.
Escape hatch. If the packaged viewer stalls on a Filament major release, you lose nothing structurally: Spatie keeps logging, and a plain ActivityResource with a table of description, causer, created_at and an infolist for properties is ~80 lines. Treat this package as a time-saver, not a dependency you can't unwind.
How can I help you explore Laravel packages today?