hamoda-dev / filament-activity-history
Beautiful, relation-aware activity history timelines for Filament panels.
Package info
github.com/hamoda-dev/filament-activity-history
pkg:composer/hamoda-dev/filament-activity-history
Requires
- php: ^8.3
- filament/filament: ^5.0
- spatie/laravel-activitylog: ^4.10
Requires (Dev)
- orchestra/testbench: ^11.0
- pestphp/pest: ^4.0
README
Relation-aware activity history timelines for Filament v5, on top of spatie/laravel-activitylog.
A record's history renders as a tree: the record's own changes, plus the changes of the relations it declares, with activities logged in the same batch nested under the change that caused them. Every line reads as a sentence — "Sara updated Price to SAR 199.00 and Stock to 5 on variant Gray / S" — in the reader's language.
The package ships no permissions, no tenancy rules and no domain formatting. Everything application-specific is supplied through closures, config, or your own translation files, so the same component drops into any project unchanged.
Contents
- Requirements
- Installation
- Logging a model
- Rendering a timeline
- Localization
- Readability
- Row actions
- Authorization and scoping
- Panel-wide defaults
- Appearance
- Configuration reference
- API reference
- Artisan commands
- Gotchas
Requirements
| PHP | 8.3+ |
| Filament | v5 |
| spatie/laravel-activitylog | ^4.10 (installed with the package) |
Installation
composer require hamoda-dev/filament-activity-history php artisan activity-history:install
The install command publishes the activity log migrations and this package's
config file, then offers to run migrate.
Those migrations belong to spatie/laravel-activitylog, which owns the
activity_log table's schema and config — the package publishes them rather
than defining its own, so the table keeps evolving with spatie rather than
forking away from it.
No asset step. The stylesheet is inlined into the panel head, so there is no
filament:assets run, no public/ artifact, and no stale cached file after an
upgrade. It uses no Tailwind sources and needs no theme rebuild. Set
assets.inline to false to serve it as a published file instead, in which case
php artisan filament:assets applies as usual.
Optional publishes: activity-history-translations, activity-history-views.
Register the plugin on each panel that should show histories:
use HamodaDev\ActivityHistory\ActivityHistoryPlugin; public function panel(Panel $panel): Panel { return $panel->plugin(ActivityHistoryPlugin::make()); }
Logging a model
Logging is spatie's job, unchanged:
use Spatie\Activitylog\LogOptions; use Spatie\Activitylog\Traits\LogsActivity; class Order extends Model { use LogsActivity; public function getActivitylogOptions(): LogOptions { return LogOptions::defaults() ->logFillable() ->logOnlyDirty() ->dontSubmitEmptyLogs(); } }
If your panels authenticate against their own guards, tell spatie which user to credit, or every change will be logged with no actor:
use Illuminate\Support\Facades\Auth; use Spatie\Activitylog\Facades\CauserResolver; CauserResolver::resolveUsing(function () { foreach (['store', 'admin', 'web'] as $guard) { if (Auth::guard($guard)->check()) { return Auth::guard($guard)->user(); } } return null; });
Relations
A record's history usually includes what happened to the things it owns. Declare those relations and they are merged into one timeline:
use HamodaDev\ActivityHistory\Contracts\HasActivityRelations; class Order extends Model implements HasActivityRelations { /** @return array<int, string> */ public function activityRelations(): array { return ['items', 'payments', 'refunds']; } }
Any Eloquent relation works — hasMany, hasOne, belongsTo, morphMany.
Soft-deleted related records are included: a child that was deleted still belongs
to its parent's history.
Prefer a property? Use the trait:
use HamodaDev\ActivityHistory\Concerns\HasActivityHistory; class Order extends Model implements HasActivityRelations { use HasActivityHistory; protected array $activityRelations = ['items', 'payments']; }
Relations can also be overridden per timeline with ->withRelations([...]).
Naming a record
By default a record is named by the first of subject_title_attributes it has.
When that would surface something technical — a slug, a SKU, a uuid — let the
model answer for itself:
use HamodaDev\ActivityHistory\Contracts\HasActivityTitle; class ProductVariant extends Model implements HasActivityTitle { public function activityTitle(): ?string { return $this->optionDescriptor() ?: null; // "Gray / S" } }
Returning null leaves the record unnamed ("on the product variant") rather than
naming it badly. Soft-deleted records are still asked — the timeline loads them
explicitly, because a morphTo cannot see them.
Rendering a timeline
The same Timeline component backs every surface below, so it is configured the
same way wherever it appears.
Slide-over action
use HamodaDev\ActivityHistory\Actions\TimelineAction; use HamodaDev\ActivityHistory\Timeline\Timeline; TimelineAction::make() ->timeline(fn (Timeline $timeline) => $timeline->searchable())
Drop it in a page header, a table row, or an infolist section. It opens a
slide-over by default; action.slide_over => false makes it a centred modal.
To send it to a full page instead of opening a modal:
TimelineAction::make() ->toPage(fn (Order $record): string => OrderResource::getUrl('history', ['record' => $record]))
Full page
Generate it:
php artisan make:activity-history-page OrderHistory --resource=OrderResource
use HamodaDev\ActivityHistory\Pages\RecordActivityPage; class OrderHistory extends RecordActivityPage { protected static string $resource = OrderResource::class; protected function timeline(): Timeline { return parent::timeline()->attributeCast(Money::class, $formatter); } }
// OrderResource::getPages() 'history' => OrderHistory::route('/{record}/history'),
The page sits the timeline in a card. Turn that off with page.card => false, or
override timelineContent() for a different wrapper.
Activity feed
A timeline with no record bound to it lists every activity, each row naming the record it happened on:
php artisan make:activity-history-page Activity --feed --panel=admin
use HamodaDev\ActivityHistory\Pages\ActivityFeedPage; class Activity extends ActivityFeedPage { protected function timeline(): Timeline { return parent::timeline()->modifyQueryUsing( fn (Builder $query) => $query->whereBelongsTo(Filament::getTenant()), ); } }
Scope it yourself — the package applies none.
Inline in a schema
Timeline::make('activity_history') ->searchable() ->maxHeight('28rem')
Anywhere a schema component fits: an infolist section, a form, a custom page.
Localization
Every string the package itself produces ships in English and Arabic. Your tables and columns are yours, and resolve from your own language file — nothing about your domain ends up in a vendor directory.
Scaffolding your translations
php artisan activity-history:translations --locale=ar --locale=en
The command walks every model using LogsActivity, reads the attributes it
actually logs (LogOptions, else $fillable), and writes
lang/{locale}/activity.php with headline defaults. Re-run it after adding a
model or a column: existing translations are never overwritten, only gaps filled.
// lang/ar/activity.php return [ 'subjects' => [ 'product_variant' => 'خيار المنتج', ], 'attributes' => [ 'product' => ['price' => 'السعر'], // this model only 'stock' => 'الكمية', // every model ], 'values' => [ 'vat_class' => ['standard' => 'الأساسية'], ], ];
In CI, --missing lists untranslated keys and exits non-zero.
How a label is resolved
First hit wins:
->attributeLabel('price', 'Selling price')on the timelineactivity.attributes.{model}.{field}— per model, for fields whose meaning differs between modelsactivity.attributes.{field}— shared across modelsvalidation.attributes.{field}— most applications already fill this in for form errors, so those fields are localized for free- the label of the matching field in the model's Filament resource form (opt in, below)
- a headline of the column name
Model names follow the same idea: activity.subjects.{model}, then the
resource's getModelLabel(), then the class name.
Borrowing Filament's labels
'translations' => ['use_filament_labels' => true],
The timeline then reads labels straight off the model's Filament resource form, so fields already labelled need no translation keys at all. It is off by default on purpose: renaming a form label would silently reword your history, and a resource that cannot be introspected is a silent miss rather than an error. Explicit language files are the predictable choice; this is the zero-config one.
Values
Values are localized too, not just labels:
- enums through Filament's
HasLabel, else their backing value - dates through ISO format tokens, so month and day names follow the locale
- numbers through
Number::format()in the active locale - foreign keys as the title of the record they point at — a translatable related model resolves in the current locale
- plain string columns through
activity.values.{field}.{value} - booleans through the package's own
yes/nostrings
Sentence structure is translated, not concatenated: conjunctions and punctuation are placed by the translation file, so Arabic's و joins the word after it while English's and stands alone.
Readability
A history is only useful if a person can read it, so the defaults lean towards plain language:
- Technical columns are hidden.
hidden_attributesdrops slugs, uuids and timestamps, and an update that touched nothing else is dropped with it rather than rendered as a bare "updated".keep_empty_updatesbrings them back. - Values are tidied. Markup is stripped, whitespace collapsed, and long
values trimmed to
max_value_lengthwith the full text in the row tooltip. - Sensitive values are masked.
masked_attributeslists the change but replaces both values withmask. - Milestones stand out.
created,deletedandrestoredget a circled, coloured icon; ordinary updates get a small node, so the eye follows the shape of the timeline. Configure withmilestone_events,iconsandcolors.
Row actions
Pass Filament actions to itemActions() and they render under each row, the way
a table renders row actions:
use Filament\Actions\Action; Timeline::make('activity_history') ->itemActions([ Action::make('viewSubject') ->label(fn (array $arguments): string => $arguments['subjectType'] === Order::class ? 'View order' : 'View item') ->icon(Heroicon::OutlinedEye) ->visible(fn (array $arguments): bool => $arguments['subjectType'] === Order::class) ->url(fn (array $arguments): string => OrderResource::getUrl('view', ['record' => $arguments['subjectId']])), Action::make('revert') ->requiresConfirmation() ->action(fn (array $arguments) => revertActivity($arguments['activity'])), ])
Each action is mounted per row with these $arguments:
| Key | Value |
|---|---|
activity |
the activity's primary key |
subjectType |
the logged morph type |
subjectId |
the logged subject key |
event |
created, updated, deleted, restored, … |
So one definition serves the whole timeline and decides for itself where it
applies. Actions render as links to suit the row density; call ->button() on
one to override. Modals, confirmations and URLs behave as they do anywhere else
in Filament.
Authorization and scoping
The package defines no permissions and no policy. Two hooks cover both:
Timeline::make('activity_history') ->authorize(fn (): bool => auth()->user()->can('viewActivityHistory')) ->modifyQueryUsing(fn (Builder $query) => $query->where('team_id', auth()->user()->team_id))
authorize() gates the whole component; modifyQueryUsing() receives the
activity query, which is where multi-tenancy, date windows and log-name filters
belong.
A record timeline is already constrained to that record's own subject ids, so a user who cannot reach the record cannot reach its history. A feed page has no such constraint — scope it explicitly.
Panel-wide defaults
Configure every timeline in a panel at once:
use HamodaDev\ActivityHistory\ActivityHistoryPlugin; use HamodaDev\ActivityHistory\Timeline\Timeline; $panel->plugin( ActivityHistoryPlugin::make()->timeline(function (Timeline $timeline): void { $timeline ->authorize(fn (): bool => Filament::auth()->check()) ->causerUrl(fn (Model $causer): string => UserResource::getUrl('view', ['record' => $causer])) ->attributeCast(Money::class, fn (int $cents): string => Currency::format($cents)); }), );
Per-instance calls always win over panel defaults.
Appearance
The stylesheet is plain CSS driven by custom properties, with logical properties throughout so RTL works without a separate build. Override the palette anywhere in your own CSS:
.fi-ah { --ah-accent: #2f7ab5; --ah-line: #e5e7eb; --ah-strong: #09090b; } .dark .fi-ah { --ah-accent: #60a5fa; }
Structural classes: .fi-ah (root), .fi-ah-timeline, .fi-ah-item,
.fi-ah-marker, .fi-ah-node, .fi-ah-icon, .fi-ah-summary, .fi-ah-strong,
.fi-ah-time, .fi-ah-actions, .fi-ah-children, .fi-ah-empty.
For deeper changes, publish the views with --tag=activity-history-views.
Configuration reference
| Key | Default | What it does |
|---|---|---|
activity_model |
null |
Model activities are read from; falls back to spatie's |
limit |
50 |
Activities loaded per timeline |
milestone_events |
created, deleted, restored |
Which events get a circled icon |
icons / colors |
heroicons / semantic | Per event, with a default entry |
translations.namespace |
activity |
Your language file for subjects and attributes |
translations.use_validation_attributes |
true |
Fall back to validation.attributes |
translations.use_filament_labels |
false |
Read labels off Filament resource forms |
values.resolve_relations |
true |
Render foreign keys as the related record's title |
values.format_numbers |
true |
Locale-aware number formatting |
causer_name_attributes |
name, full_name, title, email |
Tried in order when naming the actor |
subject_title_attributes |
name, title, label, reference, sku, code |
Tried in order when naming a record |
hidden_attributes |
slug, uuid, timestamps, remember_token |
Columns never shown |
keep_empty_updates |
false |
Keep updates whose every change was hidden |
max_value_length |
80 |
Trim long values; markup is always stripped |
masked_attributes / mask |
passwords, tokens / •••••• |
Values never rendered |
null_placeholder |
— |
Stands in for an emptied value |
date_format |
LLL |
ISO format for date attributes in a summary |
relative_timestamps |
true |
"5m ago" versus an absolute time |
short_relative_timestamps |
true |
Short units; turn off for languages that read badly abbreviated |
timestamp_format / tooltip_format |
LLL / LLLL |
ISO formats for the row time and its tooltip |
assets.inline |
true |
Inline the stylesheet instead of publishing it |
empty_state_icon / search_icon |
clock / magnifier | Icons |
page.card / page.compact_card |
true / false |
Whether page timelines sit in a card |
action.icon/color/width/slide_over |
clock, gray, large, true |
TimelineAction defaults |
API reference
Timeline
Data
| Method | Purpose |
|---|---|
withRelations(array|Closure) |
Override the relations declared on the model |
modifyQueryUsing(Closure) |
Mutate the activity query |
limit(int|null) |
Number of activities loaded |
sortActivitiesDescending(bool) |
Newest first (default) or oldest first |
inlineBatches(bool) |
Flatten batches instead of nesting them |
Wording
| Method | Purpose |
|---|---|
attributeLabel(string, string|Closure) |
Label for one attribute |
attributeValue(string, Closure) |
Formatter for one attribute |
attributeCast(string, Closure) |
Formatter for every attribute using that cast |
subjectLabel(Closure) |
How a record is named |
eventDescription(Closure) |
Replace the whole summary line |
causerName(Closure) / causerUrl(Closure) |
How the actor is shown and linked |
showCauser(bool) |
Hide the actor's name |
changesSummaryAttributeValues(bool) |
List changed fields without their values |
changesSummaryOldAttributeValues(bool) |
Include the previous value ("from X to Y") |
Appearance
| Method | Purpose |
|---|---|
itemIcon(...) / itemIconColor(...) |
Per-event icon and colour |
compact(bool) |
Tighter rows |
searchable(bool) |
Client-side filter box |
maxHeight(string) |
Scroll inside a fixed height |
emptyStateHeading/Description/Icon(...) |
Empty state |
Behaviour
| Method | Purpose |
|---|---|
authorize(bool|Closure) |
Gate the whole component |
itemActions(array) |
Actions rendered under each row |
Timeline::configureUsing(Closure) applies defaults globally, as with any
Filament component.
Contracts
| Contract | Method | Purpose |
|---|---|---|
HasActivityRelations |
activityRelations(): array |
Relations merged into the record's timeline |
HasActivityTitle |
activityTitle(): ?string |
How the record names itself |
Pages and actions
| Class | Purpose |
|---|---|
Actions\TimelineAction |
Slide-over, modal, or link to a page |
Pages\RecordActivityPage |
Full page for one record's history |
Pages\ActivityFeedPage |
Panel-wide feed of every activity |
ActivityHistoryPlugin |
Panel registration and defaults |
Artisan commands
| Command | Purpose |
|---|---|
activity-history:install |
Publish migrations and config, then migrate |
activity-history:translations |
Scaffold lang/{locale}/activity.php; --missing for CI |
make:activity-history-page |
Generate a record page (--resource=) or feed page (--feed) |
Gotchas
- A translatable attribute logs the locale that was active when it changed.
If
namewas updated while the app was in English, that entry holds the English string and cannot be shown in Arabic afterwards — the value was never recorded. Values logged as a full locale array are resolved per locale. - Scaffolding translations changes existing output. Before you run
activity-history:translations, labels may be coming fromvalidation.attributes; afterwards the generated file wins. Review the generated file rather than committing it unread. - Feed pages are unscoped by design.
modifyQueryUsing()is not optional there if your application is multi-tenant. assets.inline => falsere-introduces the publish step, and the asset is cache-busted by package version — while developing the package itself you will need a hard reload after editing the stylesheet.
License
MIT.