laboiteacode/filament-activity-timeline
A semantic activity timeline for Filament that turns raw spatie/laravel-activitylog entries into clear, localized business sentences. Show "Client: ACME France -> Dupont Conseil" instead of "customer_id: 14 -> 27". Drop-in widget, page, or infolist support, with customizabl...
The semantic activity timeline for Filament.
Turn technical activity logs into clear, localized business events.
Stop showing customer_id: 14 -> 27. Show Client: ACME France -> Dupont Conseil.

Several packages already display a timeline of spatie/laravel-activitylog
events. This one is different: it is a reusable, localized presentation layer
that turns raw model changes into sentences your users actually understand,
without spreading callbacks across every Filament resource.
From a technical activity such as:
App\Models\Order
event: updated
subject_id: 184
properties:
old.status: pending
attributes.status: paid
it produces, once the presentation is configured:
Alexandre marked order CMD-2026-0184 as paid.
And for several changes:
Alexandre updated order CMD-2026-0184
3 changes
- Status: Pending -> Paid
- Payment date: Not set -> 23 July 2026 10:42
- Payment method: Not set -> Credit card
ActivityTimelineWidget and a declarative Timeline component.ActivityTimelineEntry schema component for infolists, interactive by default with a read only static() mode.spatie/laravel-activitylog source, with cursor pagination and server side filters.spatie/laravel-activitylog 4.12 or 5 (only when using the Spatie source)composer require laboiteacode/filament-activity-timeline
When you use the Spatie integration:
composer require spatie/laravel-activitylog
php artisan vendor:publish \
--provider="Spatie\Activitylog\ActivitylogServiceProvider" \
--tag="activitylog-migrations"
php artisan migrate
Optionally publish the configuration and the translations:
php artisan vendor:publish --tag="filament-activity-timeline-config"
php artisan vendor:publish --tag="filament-activity-timeline-translations"
use LaBoiteACode\FilamentActivityTimeline\FilamentActivityTimelinePlugin;
public function panel(Panel $panel): Panel
{
return $panel->plugins([
FilamentActivityTimelinePlugin::make(),
]);
}
The plugin works with sensible defaults and no further configuration.
This package does not record activity; it presents activity recorded by another
system. With spatie/laravel-activitylog, make your model log its changes, then
show the timeline.
use Illuminate\Database\Eloquent\Model;
use Spatie\Activitylog\Models\Concerns\LogsActivity;
use Spatie\Activitylog\Support\LogOptions;
class Order extends Model
{
use LogsActivity;
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly(['status', 'total', 'customer_id', 'paid_at'])
->logOnlyDirty();
}
}
That already gives a working timeline. Everything after that is about turning those technical logs into clear, localized business sentences.
Extend the widget to configure it, then register it on a resource record page.
On a record page Filament injects the current record into the widget's $record
property automatically.
use App\Models\Order;
use LaBoiteACode\FilamentActivityTimeline\Timeline;
use LaBoiteACode\FilamentActivityTimeline\Widgets\ActivityTimelineWidget;
class OrderTimelineWidget extends ActivityTimelineWidget
{
protected function timeline(): Timeline
{
return Timeline::make()
->source('spatie')
->heading('History')
->description('Everything that happened to this order.')
->limit(15)
->loadMore()
->filters();
}
}
// App\Filament\Resources\Orders\Pages\ViewOrder
protected function getFooterWidgets(): array
{
return [
OrderTimelineWidget::class,
];
}
Closures declared in timeline() run server side on every request and are never
serialized, so the subclass has access to the full API (custom sentences,
causer resolution, title formatting).
You can also drop the widget in with plain, serializable properties and let the global registry provide the presentation:
@livewire(\LaBoiteACode\FilamentActivityTimeline\Widgets\ActivityTimelineWidget::class, [
'record' => $record,
'source' => 'spatie',
'withLoadMore' => true,
'withFilters' => true,
])
Timeline is Htmlable, so it renders anywhere a view can echo it:
use LaBoiteACode\FilamentActivityTimeline\Timeline;
Timeline::make('activity')
->record($this->record)
->loadMore()
->filters();
When rendered in a page, only serializable configuration crosses the Livewire boundary, so declare closures on a widget subclass or in the global registry.
Place the timeline in a resource infolist or any schema with
ActivityTimelineEntry. The record is provided by the infolist automatically.
use LaBoiteACode\FilamentActivityTimeline\Infolists\ActivityTimelineEntry;
public function infolist(Schema $schema): Schema // Infolist $infolist on Filament 4
{
return $schema->components([
TextEntry::make('number'),
// ...
ActivityTimelineEntry::make('activity')
->source('spatie')
->heading('History')
->perPage(10)
->loadMore()
->filters(),
]);
}
The entry proxies the timeline configuration API: source(), heading(),
description(), perPage(), loadMore(), filters(), modelLabel(),
attributes(), attributeLabels(), hiddenAttributes(), eventSentence(),
eventLabels(), eventIcons(), eventColors() and debugPresentation().
By default the entry is interactive: it embeds the timeline widget, so "load
more" and the filters work inside the infolist. Call ->static() for a read
only, non interactive list (useful for print or a frozen infolist):
ActivityTimelineEntry::make('activity')
->source('spatie')
->static();
Every property accepted by the drop-in @livewire() call, all serializable:
| Property | Type | Purpose |
|---|---|---|
record |
Model |
The record whose activity is shown. |
source |
string |
Source name (spatie by default). |
perPage |
int |
Page size, also used as the "load more" step. |
withLoadMore |
bool |
Show the "load more" button. |
withFilters |
bool |
Show the event filter tabs. |
heading / description |
string |
Section header texts. |
eventLabels / eventIcons / eventColors |
array<string, string> |
Per event overrides, keyed by event name. |
presentationOverrides |
array |
Serializable presentation overrides: modelLabel, pluralModelLabel, attributeLabels, hiddenAttributes, eventSentences. |
debug |
bool |
Presentation diagnostics (never in production). |
Declare a model presentation once, usually in a service provider. Every timeline that shows that model then benefits from it.
use App\Enums\OrderStatus;
use App\Filament\Resources\Orders\OrderResource;
use App\Models\Order;
use LaBoiteACode\FilamentActivityTimeline\ActivityTimeline;
use LaBoiteACode\FilamentActivityTimeline\Presentation\AttributePresentation;
ActivityTimeline::forModel(Order::class)
->resource(OrderResource::class)
->label('order')
->pluralLabel('orders')
->recordTitleUsing(fn (Order $order) => $order->number)
->icon('heroicon-o-shopping-cart')
->color('primary')
->attributes([
'status' => AttributePresentation::make('Status')
->enum(OrderStatus::class),
'customer_id' => AttributePresentation::make('Customer')
->relationship('customer', titleAttribute: 'name'),
'total' => AttributePresentation::make('Total')
->money('EUR'),
'paid_at' => AttributePresentation::make('Payment date')
->dateTime(),
'internal_token' => AttributePresentation::make()
->hidden(),
])
->eventSentence('created', ':causer created :subject.')
->eventSentence('updated', ':causer updated :subject.')
->eventSentence('deleted', ':causer deleted :subject.');
To start from a Filament resource (the resource provides the labels and the
record title attribute), use forResource():
ActivityTimeline::forResource(OrderResource::class)
->eventSentence('updated', ':causer updated :subject.');
A model may also expose its own presentation:
use LaBoiteACode\FilamentActivityTimeline\Contracts\HasActivityTimelinePresentation;
use LaBoiteACode\FilamentActivityTimeline\Presentation\ModelPresentation;
class Order extends Model implements HasActivityTimelinePresentation
{
public static function activityTimelinePresentation(): ModelPresentation
{
return ModelPresentation::make()
->label('order')
->recordTitleUsing(fn (Order $order): string => $order->number);
}
}
Record titles resolve in this order: an explicit recordTitleUsing() callback,
the ProvidesActivityTitle contract on the model, recordTitleAttributes(),
then a snapshot stored on the activity.
Resolution priority for the whole presentation, highest first: local timeline overrides, the global registry, the model contract, the Filament resource, then conventions (humanized class and column names).
Groups of declarations can be packaged as presets: register a preset callback
with ActivityTimeline::presentation()->registerPreset('name', $callback) and
apply it with ActivityTimeline::preset('name').
Register a presentation for any event, custom or not:
use LaBoiteACode\FilamentActivityTimeline\ActivityTimeline;
ActivityTimeline::event('invoice.sent')
->label('Invoice sent')
->sentence(':causer sent :subject to :recipient.')
->property('recipient', 'recipient_email')
->icon('heroicon-o-paper-airplane')
->color('info');
property() binds a sentence variable to a path inside the activity
properties, so business payloads stay addressable from the template.
Override an event locally on a timeline, or use a callback for full control:
use LaBoiteACode\FilamentActivityTimeline\Support\PresentationContext;
use LaBoiteACode\FilamentActivityTimeline\Data\TimelineEntry;
Timeline::make()
->eventLabels(['payment.refunded' => 'Refunded'])
->eventIcons(['payment.refunded' => 'heroicon-o-arrow-uturn-left'])
->eventColors(['payment.refunded' => 'info'])
->eventSentence('updated', ':causer actualized :subject.')
->eventSentenceUsing(
'status_changed',
fn (TimelineEntry $entry, PresentationContext $context): string =>
"{$context->causerName()} marked {$context->subject()} as {$context->newValue('status')}.",
);
Available sentence variables: :causer, :subject, :subject_label,
:subject_title, :event, :changes_count, :date and :property.path.
Inside an eventSentenceUsing() callback, the PresentationContext exposes
entry(), causerName(), subject(), subjectTitle(), subjectLabel(),
date(), newValue(), oldValue(), property() and changesCount().
The icon and color of the four Eloquent events (created, updated,
deleted, restored) are defined in the configuration file and can be changed
there globally.
Timeline::make()
->causerNameUsing(fn (?Model $causer) => $causer?->name)
->causerAvatarUsing(fn (?Model $causer) => $causer?->avatar_url);
When an activity has no causer, a configurable system identity is shown
(system_causer in the configuration, label translated through
timeline.causer.system).
For updated events, the old and new values are rendered readably. By default:
Timeline::make()
->hiddenAttributes(['password', 'remember_token'])
->attributeLabels(['email_verified_at' => 'Email verification'])
->formatAttributeUsing('status', fn (mixed $value) => OrderStatus::tryFrom($value)?->getLabel() ?? $value);
Per attribute helpers, all declarable in the registry, on a timeline or on the infolist entry:
use LaBoiteACode\FilamentActivityTimeline\Presentation\AttributePresentation;
AttributePresentation::make('Active')->boolean(); // localized Yes / No, custom labels supported
AttributePresentation::make('Due date')->date(); // date only, optional format
AttributePresentation::make('Published at')->dateTime(); // date and time, optional format
AttributePresentation::make('Amount')->money('EUR'); // localized currency, optional locale
AttributePresentation::make('Status')->enum(OrderStatus::class); // enum label, HasLabel supported
AttributePresentation::make('Tags')->list(); // arrays joined, custom glue
AttributePresentation::make('Metadata')->json(); // compact JSON rendering
AttributePresentation::make('Priority')->map(['1' => 'Low', '2' => 'High']); // value to label map
AttributePresentation::make('Customer')->relationship('customer', titleAttribute: 'name');
AttributePresentation::make('Owner')->relationshipUsing(fn ($id) => User::find($id)?->full_name);
AttributePresentation::make('Custom')->formatUsing(fn (mixed $value) => strtoupper((string) $value));
AttributePresentation::make('Internal')->hidden(); // never shown
AttributePresentation::make('API key')->redacted(); // shown as a localized "Hidden"
AttributePresentation::make('Email')->maskUsing(fn (string $value) => Str::mask($value, '*', 3, -10));
To keep a relation or subject title readable after the related record is deleted, store a snapshot in the activity properties at log time:
'presentation' => [
'subject_label' => 'order',
'subject_title' => 'CMD-2026-0184',
'attributes' => [
'customer_id' => ['old_label' => 'ACME France', 'new_label' => 'Dupont Conseil'],
],
]
Snapshots always win over a live lookup.
When you are not sure how a label, a record title or a format was resolved, enable the diagnostic output. Keep it off in production.
Timeline::make()->debugPresentation();
Server side filters by event are enabled with ->filters(). A native Filament
tab bar lets the user switch between all events and each known event.
Timeline::make()->filters(); // tabs for every known event
Timeline::make()->filters(['created', 'updated']); // tabs for these events only
Without an explicit list, the tabs are built from the events declared in the
configuration plus every event registered through ActivityTimeline::event().
Filtering happens in the source query, not in the browser, so it stays exact on
large histories.
Set the page size with ->limit() and enable progressive loading with
->loadMore(). The "load more" button is disabled while loading, disappears
when there is nothing left, keeps the already loaded items and never duplicates
an entry. Cursor pagination is used whenever the source supports it, so large
histories never load entirely into memory.
The widget is designed to disappear into your panel:
fi-color-{name} classes. A color
registered in your panel is therefore directly usable for a custom event:use Filament\Support\Colors\Color;
use Filament\Support\Facades\FilamentColor;
FilamentColor::register(['purple' => Color::Purple]);
ActivityTimeline::event('vip.upgraded')->color('purple');
fi-at-* class (fi-at-entry, fi-at-dot,
fi-at-chip, fi-at-changes, ...) for targeted CSS overrides in your theme.English and French ship with the package. Publish and edit them, or add your own
locale, with the filament-activity-timeline-translations tag.
Everything user visible is translatable: the section heading, event labels, default sentences per event, the causer fallbacks, null and boolean values, the change counters, the filter labels, the "load more" button, the empty state and the error state. Visible labels are resolved from the translation files, so the published configuration stays free of hard coded strings.
Publish config/filament-activity-timeline.php to change the defaults:
default_source: the source used when a timeline does not pick one.pagination.per_page and pagination.mode (load_more or simple).date_format and timezone for the absolute date shown on hover.system_causer: the identity shown when an activity has no causer.events: the icon and color for each known event.hidden_attributes: attribute names that are never shown in a change list.attributes.truncate: the maximum length of a rendered string value.relations.resolve: resolve relation identifiers to titles.debug: presentation diagnostics, never in production.The Spatie source is registered as spatie by default. Register another source
(a custom table, an external API, another package) by name in a service
provider:
use LaBoiteACode\FilamentActivityTimeline\ActivityTimeline;
ActivityTimeline::registerSource('audit', fn () => new AuditSource());
A source implements
LaBoiteACode\FilamentActivityTimeline\Contracts\ActivitySource: it is scoped
with forRecord(), filtered with events(), ordered with latestFirst() and
read with paginate(), returning normalized TimelineEntry objects inside a
TimelineResult. The presentation layer never depends on a specific logging
library. Select it with ->source('audit').
composer test
The suite runs against Filament 4 and 5, Livewire 3 and 4, Laravel 12 and 13, and PHP 8.3 to 8.5.
Please see CONTRIBUTING.md for details.
Please see SECURITY.md for reporting vulnerabilities.
The MIT License (MIT). Please see LICENSE.md for more information.
How can I help you explore Laravel packages today?