relaticle/activity-log
A flexible activity logging package for Laravel that records model changes and user actions with an extensible API. Track who did what and when, with causers, subjects, custom log names, and easy querying for audit trails and timelines.
Install and prerequisites
composer require relaticle/activity-log
The package is a thin Filament presentation layer over spatie/laravel-activitylog, so expect both a Filament v3 dependency and the Spatie package as a transitive (or peer) requirement. If activity_log migrations aren't already in your app:
php artisan vendor:publish --provider="Spatie\Activitylog\ActivitylogServiceProvider" --tag="activitylog-migrations"
php artisan migrate
First three things to read, in order
src/ — start with the Filament component/Livewire class that renders the timeline. That file tells you the exact public API (properties, ->record(), ->subject() or similar), which is the thing that churns fastest in a young package.php artisan vendor:publish --tag="activity-log-config"). This is where event-to-renderer mappings, icon/color maps, and causer resolution live. Everything you'll want to customize on day two is in here.First use case: a timeline on a record view page
The canonical integration is a Filament Resource ViewRecord page (or an Infolist schema) for a primary domain model — a Project, Ticket, Order, Lead. Add the timeline component/relation manager to the page, point it at the record, and you immediately get every activity_log row whose subject is that model, rendered with causer avatar, event label, and relative timestamp.
Before wiring it up, make sure the model actually logs something. Either use Spatie's LogsActivity trait with getActivitylogOptions(), or log explicitly:
activity()
->performedOn($project)
->causedBy(auth()->user())
->withProperties(['status' => 'active', 'from' => 'draft'])
->log('status_changed');
If the timeline renders empty, the problem is 95% of the time that nothing is being written to activity_log — not the Filament component.
Sanity check before building on it
17 stars and a single-maintainer cadence means you should pin the exact version in composer.json and read the upgrade notes between minors. Treat this as a "vendor it if it breaks" dependency, not a load-bearing framework.
Pattern 1 — Relation manager on detail pages (the default path)
Attach the timeline as a Filament relation manager on the resource's view page so it lives alongside tabs for related records. This gives you free pagination, per-page scoping, and a natural place to put filters (event type, causer, date range) later. Prefer this over a custom page unless you need cross-record aggregation.
Pattern 2 — Explicit logging at domain boundaries
Don't rely solely on LogsActivity's automatic created/updated/deleted capture for the interesting stuff. Those rows are noisy and low-signal. Instead, log semantic events from the place where the domain decision is made — actions, jobs, state transitions:
final class ApproveInvoice
{
public function handle(Invoice $invoice, User $approver): void
{
DB::transaction(function () use ($invoice, $approver) {
$invoice->update(['approved_at' => now()]);
activity()
->performedOn($invoice)
->causedBy($approver)
->event('approved')
->withProperties(['amount' => $invoice->total_cents])
->log('invoice approved');
});
}
}
Keeping the activity() call inside the same transaction as the write is the single most important discipline — otherwise you get orphaned or missing timeline entries.
Pattern 3 — Custom renderers per event
The real leverage is mapping event names to tailored UI. The plugin's extension point is typically a renderer/definition class registered in config, keyed by event name (approved, status_changed, commented). Implement one per event family so the timeline reads like a product feed rather than a database dump:
// config/activity-log.php (shape is indicative — confirm against source)
'events' => [
'approved' => App\Activity\ApprovedRenderer::class,
'status_changed' => App\Activity\StatusChangedRenderer::class,
'default' => App\Activity\GenericRenderer::class,
],
Each renderer owns its icon, color, and human sentence, reading structured data out of $activity->properties. Keep the sentence composition in the renderer, not in the logging call — you'll want to reword the copy without backfilling rows.
Pattern 4 — Structured properties, never prose
Always store machine-readable keys (['from' => 'draft', 'to' => 'active']), and let the renderer build the string. This is what makes the timeline translatable, filterable, and re-renderable after a copy change. Adopt a loose convention and document it in the repo (snake_case, primitives only, no nested models — store IDs).
Pattern 5 — Polymorphic subject, consistent causer
Rely on Spatie's subject morph for the record being viewed, and make sure causer is consistently a User (or whatever your actor model is). Filament's user menu / avatar resolution expects a stable causer shape. If you have multiple actor types (users, API tokens, system), resolve a display name and avatar URL in one place — usually a small CauserPresenter — and reference it from every renderer. Handle causer === null explicitly as "System" rather than blowing up.
Pattern 6 — Polling for near-real-time
For ticket/CRM-style pages where someone else may be acting concurrently, set protected static ?string $pollingInterval = '15s' (or the plugin's equivalent) on the timeline component. Do not poll the whole page — scope it to the timeline component.
Pattern 7 — Testing
Activity logging is easy to assert and easy to silently break during refactors. Add a dedicated test per critical event:
$this->assertDatabaseHas('activity_log', [
'subject_type' => (new Invoice)->getMorphClass(),
'subject_id' => $invoice->id,
'event' => 'approved',
'causer_id' => $user->id,
]);
Use getMorphClass() rather than the FQCN so morph-map changes don't churn your tests.
The activity_log table grows forever. This is the number one production surprise. Schedule the built-in cleanup immediately, and configure delete_records_older_than_days plus a tap to preserve audit-critical events:
// app/Console/Kernel.php
$schedule->command('activitylog:clean')->daily();
Without a retention policy, a busy model will push this table into millions of rows within a year, and the timeline query (which orders by created_at descending with a subject filter) will degrade. Index (subject_type, subject_id, created_at) if the migration doesn't already — check it.
N+1 on causer and subject. The timeline eager-loads nothing by default in most implementations. If your renderers touch $activity->causer->name and $activity->subject->title, you're doing two queries per row on a 25-row page. Eager load ['causer', 'subject'] on the timeline's underlying query — usually a modifyQueryUsing() hook or an overridden getTableQuery(). On soft-deleted subjects, this silently resolves to null; renderers must guard.
Morph map instability. If you introduce Relation::enforceMorphMap() after rows already exist, historical subject_type values (FQCNs) no longer resolve and the timeline shows blank subjects. Decide on morph maps before you accumulate history, or write a one-time backfill migration.
LogsActivity with logAll() is a trap. It records every attribute on every save, including updated_at, last_seen_at, and password-adjacent fields. The timeline becomes unreadable and you burn storage. Use explicit logOnly([...]) with logOnlyDirty() and dontLogIfAttributesChangedOnly() for noisy columns. Also exclude sensitive attributes via activitylog's dontLogIfAttributesChangedOnly / logExcept — an accidental password hash or token in properties is a real security incident.
Noisy updated events drown semantic ones. Either suppress default model events for heavily-edited models, or give the timeline a default filter that hides updated unless explicitly requested. Users perceive the timeline as broken when it's 40 rows of "updated".
Multi-tenancy/schema scoping. If you use a tenancy package, activity_log rows are not automatically partitioned by tenant. The timeline component must be pointed at a record you've already authorized (Filament handles this), but any global activity feed needs an explicit tenant column and query scope. Add tenant_id to the table if you plan a cross-record feed later — retrofitting is painful.
Timezones and relative timestamps. created_at is stored in the app timezone but rendered to the panel user. Filament's ->since() / relative display is convenient but confusing on audit timelines — offer both relative and absolute on hover, or use absolute for anything compliance-adjacent.
Event name collisions. event is a plain string column with no constraint. Two features logging 'approved' for different meanings will render through the same renderer and produce wrong copy. Namespace your events (invoice.approved, ticket.approved) from day one.
Config caching. Because the renderer map lives in config, remember php artisan config:cache locally after edits — otherwise you'll debug a stale renderer for ten minutes.
Batching. For bulk operations (imports, mass status change), write one activity row with a count rather than N rows, or the timeline for the parent record explodes. Where per-row detail matters, log on the child records and surface an aggregate entry on the parent only.
Forking readiness. At 17 stars, assume upstream may stall. Keep your customizations in app/Activity/ renderers (data-driven, from config) rather than by overriding package classes — that way a fork or a replacement package is a config swap, not a rewrite. Avoid patching vendor/ directly; if you must, use composer-patches so the change is visible in composer.json.
Compatibility pinning. Filament v3 → v4 is a breaking change for nearly every plugin in this space. Pin both filament/filament and this package in composer.json, and check the package's CI matrix before upgrading the panel.
How can I help you explore Laravel packages today?