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.
- Requirements
- Installation
- Quick start
- Core concepts
- Data sources
- Filtering, sorting, dedup
- Filament UI integrations
- Custom renderers
- Caching
- Configuration reference
- Tailwind
- Performance notes
- Testing
- PHP 8.4+
- Laravel 12
- Filament 5
spatie/laravel-activitylog^5
composer require relaticle/activity-logThe 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
php artisan vendor:publish --tag=activity-log-configThe 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']);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/**/*';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());
});
}
}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.
| 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. |
All sources are registered fluently on TimelineBuilder. You can mix any number of them in one timeline.
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).
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."
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). |
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'],
);
}
})For reusable sources, implement Relaticle\ActivityLog\Contracts\TimelineSource and pass it directly. Useful when the resolution logic warrants its own class.
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.
| 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()). |
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).
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.
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;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.
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.
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
]),
);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('...'));// config/activity-log.php
'renderers' => [
'email_sent' => \App\Timeline\Renderers\EmailSentRenderer::class,
],For each TimelineEntry, the registry checks:
$entry->renderer(an explicit override the source set)bindings[$entry->event]bindings[$entry->type]DefaultRendererfallback
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]);
}
}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.
// 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',
],
];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/**/*';- 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.
cd Plugins/ActivityLog
composer install
vendor/bin/pestThe package ships fixtures (Person, Email, Note, Task) in tests/Fixtures/ and uses Orchestra Testbench for isolation.
