relaticle / activity-log
Reusable Filament v5 plugin that renders a unified activity-log timeline for any Eloquent model.
Requires
- php: ^8.4
- filament/filament: ^5.0
- illuminate/contracts: ^12.0
- illuminate/database: ^12.0
- illuminate/support: ^12.0
- spatie/laravel-activitylog: ^5.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- orchestra/testbench: ^10.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- pestphp/pest-plugin-livewire: ^4.0
README
A reusable Filament v5 plugin that renders a unified chronological timeline for any Eloquent model. It aggregates events from spatie/laravel-activitylog (both the model's own log and logs of its related models), timestamp columns on related models (e.g. emails.sent_at, tasks.completed_at), and any custom source you define.
The plugin ships an infolist component, two relation managers, two header actions, and a facade for registering custom renderers.
Table of contents
- Requirements
- Installation
- Quick start
- Core concepts
- Data sources
- Filtering, sorting, dedup
- Filament UI integrations
- Custom renderers
- Caching
- Configuration reference
- Tailwind
- Performance notes
- Testing
Requirements
- PHP 8.4+
- Laravel 12
- Filament 5
spatie/laravel-activitylog^5
Installation
1. Require the package
composer require relaticle/activity-log
The service provider (Relaticle\ActivityLog\ActivityLogServiceProvider) is auto-discovered. It registers:
- Config file (
config/activity-log.php) - Blade views namespaced as
activity-log::* - Translations
RendererRegistryandTimelineCachesingletons- Two Livewire components:
timeline-livewire,activity-log-list
2. Publish the config (optional)
php artisan vendor:publish --tag=activity-log-config
3. Ensure the activity_log table is indexed
The plugin does not ship a migration (the table is owned by spatie/laravel-activitylog). For good performance on timeline queries, add this compound index:
$table->index(['subject_type', 'subject_id', 'created_at']);
4. Add the Tailwind source (for custom panel themes)
If your panel uses a custom theme.css, add the plugin's views to its source list so Tailwind compiles the utilities used by the Blade templates:
/* resources/css/filament/{panel}/theme.css */ @source '../../../../vendor/relaticle/activity-log/resources/views/**/*';
Quick start
1. Mark the model as timeline-capable
Implement the HasTimeline contract, use the InteractsWithTimeline trait for the helper methods, and define a timeline(): TimelineBuilder method:
use Illuminate\Database\Eloquent\Model; use Relaticle\ActivityLog\Concerns\InteractsWithTimeline; use Relaticle\ActivityLog\Contracts\HasTimeline; use Relaticle\ActivityLog\Timeline\TimelineBuilder; use Relaticle\ActivityLog\Timeline\Sources\RelatedModelSource; use Spatie\Activitylog\Traits\LogsActivity; class Person extends Model implements HasTimeline { use InteractsWithTimeline; use LogsActivity; public function timeline(): TimelineBuilder { return TimelineBuilder::make($this) ->fromActivityLog() ->fromActivityLogOf(['emails', 'notes', 'tasks']) ->fromRelation('emails', function (RelatedModelSource $source): void { $source ->event('sent_at', 'email_sent', icon: 'heroicon-o-paper-airplane', color: 'primary') ->event('received_at', 'email_received', icon: 'heroicon-o-inbox-arrow-down', color: 'info') ->title(fn ($email): string => $email->subject ?? 'Email') ->causer(fn ($email) => $email->from->first()); }); } }
2. Render the timeline on the resource's view page
use Filament\Schemas\Schema; use Relaticle\ActivityLog\Filament\Infolists\Components\ActivityLog; public static function infolist(Schema $schema): Schema { return $schema->components([ ActivityLog::make('timeline') ->heading('Activity') ->groupByDate() ->perPage(20) ->columnSpanFull(), ]); }
That's the minimum setup. The sections below cover customization.
Core concepts
| Concept | What it represents |
|---|---|
TimelineBuilder |
Fluent builder that composes sources, applies filters, and returns paginated TimelineEntry collections. Built per-record via $record->timeline(). |
TimelineSource |
Produces TimelineEntry objects from a specific origin (spatie log, related timestamps, custom closure). Implementations: ActivityLogSource, RelatedActivityLogSource, RelatedModelSource, CustomEventSource. |
TimelineEntry |
Immutable value object describing a single event: event, occurredAt, title, description, icon, color, subject, causer, relatedModel, properties, plus an optional renderer key. |
TimelineRenderer |
Converts a TimelineEntry into a Blade View or HtmlString. The default renderer handles every entry; you register custom renderers per event or type. |
| Priority | Each source carries a priority. When two entries share a dedupKey, the higher-priority one wins. Defaults: activity_log=10, related_activity_log=10, related_model=20, custom=30. |
Data sources
All sources are registered fluently on TimelineBuilder. You can mix any number of them in one timeline.
fromActivityLog() — the record's own spatie log
TimelineBuilder::make($this)->fromActivityLog();
Reads rows from activity_log where subject_type + subject_id match $this. Entry event = the spatie event column (or description as fallback).
fromActivityLogOf(array $relations) — related models' spatie logs
TimelineBuilder::make($this)->fromActivityLogOf(['emails', 'notes', 'tasks']);
For each named relation, reads activity_log rows whose subject matches any related record. Useful for "show me everything that happened to anything attached to this person."
fromRelation(string $relation, Closure $configure) — timestamp columns
Turns rows on a related model into timeline entries keyed by a timestamp column. Ideal when related records already carry canonical timestamps (sent_at, completed_at, created_at) and you don't need spatie-style change logs.
->fromRelation('tasks', function (RelatedModelSource $source): void { $source ->event('completed_at', 'task_completed', icon: 'heroicon-o-check-circle', color: 'success') ->event('created_at', 'task_created', icon: 'heroicon-o-plus-circle') ->with(['creator', 'assignee']) // eager loads ->using(fn ($query) => $query->whereNull('archived_at')) // extra constraints ->title(fn ($task): string => $task->title ?? 'Task') ->description(fn ($task): ?string => $task->summary) ->causer('creator'); // relation name or Closure })
RelatedModelSource API:
| Method | Purpose |
|---|---|
event(string $column, string $event, ?string $icon, ?string $color, ?Closure $when) |
Register one event per timestamp column. when is an optional row-level filter (return bool). |
with(array $relations) |
Eager-loads relations on every event query — prevents N+1 in renderers. |
using(Closure $modifier) |
Arbitrary query modifier (scope injection, tenant scoping, etc.). |
title(Closure) / description(Closure) |
Per-row resolver for display fields. |
causer(Closure|string) |
Resolves the actor. string is a relation name on the row; closure returns a Model (or null). |
fromCustom(Closure $resolver) — anything else
When the data isn't in activity_log and isn't a relation (e.g. entries coming from an external API), yield your own TimelineEntry objects:
->fromCustom(function (Model $subject, Window $window): iterable { foreach (ExternalApi::events($subject, $window->from, $window->to, $window->cap) as $row) { yield new TimelineEntry( id: 'external:'.$row['id'], type: 'custom', event: $row['event'], occurredAt: CarbonImmutable::parse($row['at']), dedupKey: 'external:'.$row['id'], sourcePriority: 30, title: $row['title'], ); } })
addSource(TimelineSource $source) — drop-in custom sources
For reusable sources, implement Relaticle\ActivityLog\Contracts\TimelineSource and pass it directly. Useful when the resolution logic warrants its own class.
Filtering, sorting, dedup
All methods are chainable on TimelineBuilder:
$record->timeline() ->between(now()->subMonth(), now()) // CarbonInterface|null on each side ->ofType(['related_model', 'activity_log']) // allow-list ->exceptType(['custom']) // deny-list ->ofEvent(['email_sent', 'task_completed']) ->exceptEvent(['draft_saved']) ->sortByDateDesc() // default; use sortByDateAsc() for ascending ->deduplicate(false) // default: true ->dedupKeyUsing(fn ($entry) => $entry->type.':'.$entry->event.':'.$entry->occurredAt->toDateString()) ->paginate(perPage: 20, page: 1);
Dedup behaviour: entries sharing a dedupKey collapse to the highest sourcePriority (first occurrence wins on ties). Override the key with dedupKeyUsing() if the default identity isn't right for your use case.
Methods that run the query
| Method | Returns |
|---|---|
get() |
Collection<int, TimelineEntry> — all entries up to the internal 10 000 cap. |
paginate(?int $perPage, int $page = 1) |
LengthAwarePaginator<int, TimelineEntry>. Uses activity-log.default_per_page if $perPage is null. |
count() |
int (runs get()). |
Filament UI integrations
Infolist components
Two infolist entries are shipped. Both call $record->timeline() and require HasTimeline.
use Relaticle\ActivityLog\Filament\Infolists\Components\ActivityLog; // spatie-style flat activity log use Relaticle\ActivityLog\Filament\Infolists\Components\Timeline; // unified date-grouped timeline ActivityLog::make('activity') ->heading('Activity') ->groupByDate() // group by today / yesterday / this week / last week / this month / older ->collapsible() // allow collapsing groups (only meaningful with groupByDate) ->perPage(20) // overrides activity-log.default_per_page ->emptyState('No activity yet.') // custom empty-state message ->infiniteScroll(false) // false = "Load more" button (default for ActivityLog); true = wire:intersect ->using(fn (Person $record) => $record->timeline()->exceptEvent(['draft_saved'])) ->columnSpanFull(); Timeline::make('timeline') ->heading('Timeline') ->groupByDate() ->perPage(3) // Timeline default ->infiniteScroll(true) // true = wire:intersect (default for Timeline); false = "Load more" button ->columnSpanFull();
Pass ->using(Closure) to mutate or replace the builder (e.g., for role-based filtering).
Pagination UX: infiniteScroll(bool)
Both entries expose an infiniteScroll() fluent flag that switches the bottom control:
true— renders awire:intersectsentinel; the next page loads automatically as the user scrolls (Livewire 4).false— renders aLoad morebutton the user clicks.
Defaults: ActivityLog = false, Timeline = true.
Relation managers
Two read-only relation managers render the timeline as a tab on the resource's view/edit page:
use Relaticle\ActivityLog\Filament\RelationManagers\ActivityLogRelationManager; // flat list, spatie-style use Relaticle\ActivityLog\Filament\RelationManagers\TimelineRelationManager; // date-grouped, unified public static function getRelations(): array { return [TimelineRelationManager::class]; }
Both override canViewForRecord() to always return true. They declare a dummy HasOne relationship so they don't write to the DB — the page just hosts a Livewire component.
Each relation manager carries a protected static bool $infiniteScroll default (false for ActivityLogRelationManager, true for TimelineRelationManager) that is forwarded to the Livewire component. Flip it from a service provider if you want the opposite UX:
TimelineRelationManager::$infiniteScroll = false;
Header actions
Show the timeline in a slide-over modal from any resource table or page header:
use Relaticle\ActivityLog\Filament\Actions\TimelineAction; use Relaticle\ActivityLog\Filament\Actions\ActivityLogAction; protected function getHeaderActions(): array { return [ TimelineAction::make(), // unified timeline (emails/notes/tasks + spatie) ActivityLogAction::make(), // spatie-style flat activity list ]; }
Both actions open a 2XL slide-over with the relevant Livewire component. Customize label/icon/modal width as with any Filament action.
Custom renderers
Out of the box, every entry renders via DefaultRenderer (emits title, description, causer, relative time, and a colored icon). For branded output per event type, register a custom renderer.
Registering via the panel plugin
use Relaticle\ActivityLog\ActivityLogPlugin; $panel->plugin( ActivityLogPlugin::make()->renderers([ 'email_sent' => \App\Timeline\Renderers\EmailSentRenderer::class, 'note_added' => 'my-app::timeline.note-added', // view name 'task_done' => fn ($entry) => new HtmlString('...'), // closure ]), );
Registering via the facade (e.g., from a service provider)
use Relaticle\ActivityLog\Facades\Timeline; Timeline::registerRenderer('email_sent', \App\Timeline\Renderers\EmailSentRenderer::class); Timeline::registerRenderer('note_added', 'my-app::timeline.note-added'); Timeline::registerRenderer('task_done', fn ($entry) => new HtmlString('...'));
Registering via config
// config/activity-log.php 'renderers' => [ 'email_sent' => \App\Timeline\Renderers\EmailSentRenderer::class, ],
Renderer resolution order
For each TimelineEntry, the registry checks:
$entry->renderer(an explicit override the source set)bindings[$entry->event]bindings[$entry->type]DefaultRendererfallback
Renderer forms
A renderer binding can be any of:
- Class string implementing
Relaticle\ActivityLog\Contracts\TimelineRenderer - Closure
fn (TimelineEntry $entry): View|HtmlString => ... - View name (e.g.,
'my-app::timeline.email-sent') — receives$entryin scope
final class EmailSentRenderer implements \Relaticle\ActivityLog\Contracts\TimelineRenderer { public function render(\Relaticle\ActivityLog\Timeline\TimelineEntry $entry): \Illuminate\Contracts\View\View { return view('app.timeline.email-sent', ['entry' => $entry]); } }
Caching
Opt-in per call — disabled by default.
$record->timeline()->cached(ttlSeconds: 300)->paginate();
Invalidate when mutations occur (consumer-driven; the plugin doesn't observe your models):
$record->forgetTimelineCache();
Configure the cache store and key prefix in config/activity-log.php under cache.
Configuration reference
// config/activity-log.php return [ // Default page size when ->perPage() isn't called. 'default_per_page' => 20, // Per-source over-fetch buffer: cap = perPage * (page + buffer). // Higher = safer dedup/filtering at higher pages; more DB work. 'pagination_buffer' => 2, // Whether dedup is on by default (builder->deduplicate(bool) overrides). 'deduplicate_by_default' => true, // Per-source priority. Higher wins on dedup collisions. 'source_priorities' => [ 'activity_log' => 10, 'related_activity_log' => 10, 'related_model' => 20, 'custom' => 30, ], // Labels the infolist component uses when ->groupByDate() is enabled. 'date_groups' => ['today', 'yesterday', 'this_week', 'last_week', 'this_month', 'older'], // Event-or-type → renderer binding. Merged with bindings from the plugin/facade. 'renderers' => [ // 'email_sent' => \App\Timeline\Renderers\EmailSentRenderer::class, ], 'cache' => [ 'store' => null, // null = default cache store 'ttl_seconds' => 0, // 0 = no caching (use ->cached() per call) 'key_prefix' => 'activity-log', ], ];
Tailwind
The plugin's Blade views use Tailwind utilities. If your panel has a compiled theme, include the plugin's views in its source list:
/* resources/css/filament/{panel}/theme.css */ @source '../../../../vendor/relaticle/activity-log/resources/views/**/*';
Performance notes
- Every source batch-loads; no N+1 in the core path. Use
->with([...])onRelatedModelSourceif your renderer/title resolver reads relations. - Pagination over-fetches by
perPage × (page + pagination_buffer)per source so dedup/filtering stays correct at higher pages. Tunepagination_bufferif your sources rarely collide. get()is capped at 10 000 entries. For unbounded history, paginate.- Add the
['subject_type', 'subject_id', 'created_at']compound index onactivity_log.
Testing
cd Plugins/ActivityLog
composer install
vendor/bin/pest
The package ships fixtures (Person, Email, Note, Task) in tests/Fixtures/ and uses Orchestra Testbench for isolation.
