jaocero/activity-timeline
jaocero/activity-timeline is a Laravel package for recording and displaying user activity timelines. It helps log model events and custom actions, then present them as a clean, queryable feed for dashboards and audit views.
This plugin is a thin, opinionated Filament view layer on top of spatie/laravel-activitylog. Nothing works until Spatie is installed and logging — so set that up first, verify a row lands in activity_log, and only then add the timeline UI.
Minimal path:
composer require spatie/laravel-activitylog
php artisan vendor:publish --provider="Spatie\Activitylog\ActivitylogServiceProvider" --tag="activitylog-migrations"
php artisan migrate
LogsActivity trait to at least one model (e.g. Post) with getActivitylogOptions() returning LogOptions::defaults()->logAll().composer require jaocero/activity-timeline
php artisan vendor:publish --tag="activity-timeline-config"
use JaOcero\ActivityTimeline\Components\ActivitySection;
public function infolist(Infolist $infolist): Infolist
{
return $infolist->schema([
ActivitySection::make()
->subject($this->record),
]);
}
Where to look first: open config/activity-timeline.php immediately after publishing. The activities_model key is the single most important setting — it's what lets you point at a custom Activity model (Spatie supports model swapping well). Also skim the config for the entry view path and any date/time renderer options.
First real use case: a ViewUser infolist page showing "what happened to this account" — logins, role changes, email updates. That's the package's sweet spot, and it's a 10-line win for admin panels.
Infolist section (most common). Treat ActivitySection like any other infolist component. It plays well with siblings, so you can put it in a Section with collapsible behaviour or place it as the last entry in a two-column layout. Constrain the query so you're not dumping the entire activity_log table into one record:
ActivitySection::make()
->subject($this->record)
->modifyQueryUsing(fn ($query) => $query->where('log_name', 'user')->latest());
Custom page. For a dedicated "Activity" tab, extend the plugin's page class and register it on your resource's getPages(). This is the pattern to use when the timeline is the whole page rather than a section — it gives you a full-width canvas and a cleaner URL like /admin/users/42/activity.
Multiple named timelines. Filter by log_name, event, or causer_type to build purpose-built feeds — e.g. "Security events" (->where('event', 'login')), "Data changes" (->where('event', 'updated')), "Admin actions" (->whereHas('causer', fn ($q) => $q->whereHas('roles', fn ($r) => $r->where('name', 'admin')))). One subject, many angles.
Custom entries. When Spatie's properties are rich (before/after diffs, tags, IPs), render them yourself. The config points to a Blade view; override it and use $activity->properties->toArray() inside. This is the cleanest escape hatch — you rarely need to fork the component.
Combining with manual activity calls. You rarely need LogsActivity on everything. Sprinkle explicit calls where you need semantic events:
activity('billing')
->performedOn($user)
->causedBy(auth()->user())
->withProperties(['plan' => 'pro'])
->log('upgraded plan');
Then give it its own timeline (see above) or include log_name = ['user', 'billing'] in the query.
Multi-tenancy / scoping. For team-based apps, the timeline is only safe if it's filtered to the tenant. Either scope in ->modifyQueryUsing() with where('properties->team_id', ...), or — better — put the scoping in a custom Activity model's global scope so no other page can accidentally leak it.
filament-activity-log. There are several Filament + Spatie packages with similar names; don't mix their configs or component namespaces. If ActivitySection "doesn't exist", you've almost certainly got the wrong package required (or the wrong Filament major version installed).composer.json constraint on the release before you plan an upgrade.Activity eager-loads nothing by default. With a feed of 50+ entries, you'll feel it. Constrain the query with ->with(['causer', 'subject']) inside modifyQueryUsing() — this is the single biggest performance win on large logs.activity_log table grows. Index subject_type, subject_id and log_name (Spatie ships some of these; verify with SHOW INDEX). Configure activitylog.clean_after_days (or a scheduled activitylog:clean command) — a timeline page is a read-heavy view and will get slow without pruning.causer is often null — console commands, queue workers, seeders, and system jobs don't have an authenticated user. The default entry view may render an empty name or break if you don't handle null. Guard in Blade with {{ $activity->causer?->name ?? 'System' }} and, if you replace the entry view, test the null case explicitly.ActivitySection will happily render whatever the query returns, including old email addresses, IPs, password-reset markers, and deleted records. Either gate the whole page with ->authorize()/canView(), or filter entries by permission inside modifyQueryUsing(). Don't rely on "the user would have to guess the URL" — Filament generates routes.Activity model is a one-way door. Updating activities_model in the config means new features (property casts, custom relationships) need to live on your model, not Spatie's. Decide early if you need before/after diff helpers or tenant scopes — retrofitting later means rewriting queries and views simultaneously.->latest(). The default ordering in the plugin is usually sensible, but once you add a whereIn on log_name or a causer join, order can drift. Explicitly ->orderByDesc('created_at') in your modifier callback; a timeline that renders oldest-first looks broken.ActivitySection is a Livewire component like the rest of Filament. If you log activity from the same page (e.g. a "Mark as reviewed" action), the timeline won't refresh until you dispatch $this->record->touch() or trigger a refreshFormData/$this->dispatch('$refresh'). Wrap your actions accordingly, or accept a manual refresh. Filament's ->poll() is not available on infolist entries in v3 — factor that into the UX.Activity::fake() in tests and assert with Activity::assertLogged('updated'). Asserting on the rendered timeline is brittle (dates, relative times); test the query/scoping logic separately by hitting the page as an authorized and an unauthorized user.How can I help you explore Laravel packages today?