Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Filament Activity Timeline Laravel Package

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...

View on GitHub
Deep Wiki
Context7

Getting Started

  1. Install alongside the base logger. This package is a presentation layer over spatie/laravel-activitylog, so confirm that dependency is present and its activity_log table migrated before anything else:

    composer require laboiteacode/filament-activity-timeline
    php artisan vendor:publish --tag="filament-activity-timeline-config"
    
  2. Register the plugin on the panel in your PanelProvider (not globally — panels are opt-in):

    ->plugin(ActivityTimelinePlugin::make())
    
  3. Look first at the config file, not the README. It is where the package tells you its actual opinions: which log names are surfaced, how subjects and causers are resolved to display names, where the translation/label files live, and now the pagination.max_per_page ceiling. Read the published config + the package's resources/lang directory side by side — that pair defines the whole semantic mapping and is the fastest way to understand the design.

  4. First real use case: attach a timeline to a record page you already log. In the resource's getRelations() (or the page's getRelationManagers()), add the package's timeline relation manager/component against the model, and open a record that has existing activity_log rows. If the timeline renders raw attribute diffs instead of sentences, that is expected on first pass — the package's value is that you then replace those diffs with your own localized event descriptions (see below).

  5. If you are upgrading to v1.0.2 (or later), audit before you ship. No code change is required for a standard setup, but two things bite non-standard ones: any $wire.set(...) against the widget now throws CannotUpdateLockedPropertyException, and any custom source throwing AuthorizationException now surfaces a 403 instead of the error panel. Add 'max_per_page' => 500 under pagination in an already-published config if you want the ceiling explicit rather than implicit.

Do not attempt a greenfield "activity" subsystem. The pragmatic starting point is: existing activity_log rows → readable timeline on one resource → then progressively annotate the events that matter.

Implementation Patterns

Annotate events, don't log more. The core workflow is a mapping from (log_name, event, subject_type) to a human sentence with named attributes. Day-to-day this means every time you add activity()->log('...') or ->event('...') in a model or action, you immediately add the corresponding label/translation entry. Treat the two as one change in the same PR, or the timeline silently degrades to technical output.

Centralize the vocabulary once. Resist per-model ad-hoc strings. Keep a single translation namespace (e.g. lang/{locale}/activity.php) with keys like:

'client.updated' => 'Client :name updated',
'attributes' => [
    'customer_id' => 'Client',
    'vat_number' => 'VAT number',
],

Then a change to wording or a new locale is a one-file diff rather than a hunt through resources. This is the package's whole thesis — technical logs → business events — so the translation file is the product surface.

Attribute labels are the highest-leverage config. Most timelines in real apps are updated events on models with 20 columns. Mapping the five columns business users care about (and hiding the rest via the package's ignored/visible attributes setting) turns noise into signal with almost no code. Note the distinction the security release made explicit: hiddenAttributes set on the widget at mount time, and hidden_attributes in config, are two different layers — the config one is the one that held firm. Put genuinely sensitive columns in hidden_attributes, not only on the widget.

Causer and subject resolution. Make sure causer resolves to a display name in the UI, and that system/cron/queue actions are labelled distinctly (e.g. "System" or the job name) rather than showing as a blank user. In Filament apps this is usually the difference between a timeline people trust and one they ignore. If you use a morph map, register the aliases in the package's morph map config too, or subjects render as class names.

Configure the widget through mount() or by extending timeline() — never from JavaScript. Since v1.0.2 every public widget property is #[Locked]. If you were reaching for $wire.set('per_page', 100) from Alpine or a custom view, that is now an exception. Move the same intent into a widget subclass, a mount() argument, or the config default. This is a cleaner seam anyway: it keeps widget state server-defined and testable.

Bound your page sizes deliberately. The pagination.max_per_page ceiling (500 by default) applies to the "load more" button as well as to crafted requests. If a per_page in config is above the ceiling it is still honoured in full — the ceiling clamps runtime growth, not your declared intent. Set both numbers where a human can read them, and treat per_page of 0 or below as clamped to 1 (silently, so validate in your own code if a human can type it).

Placement patterns that work well:

  • Relation manager on a detail page for record-scoped history ("what happened to this client").
  • Global/dashboard timeline for the ops team ("what happened today across the app") — filter hard by log name here, and cap the query.
  • Tabbed timelines (e.g. "Changes", "Notes", "Emails") by mapping log names to tabs, rather than one long mixed feed. Remember filterByEvent() only accepts an event the timeline already offers as a filter tab, so tabs are the contract for what is filterable.

Filter by log name at the source. Segment by log name (client, billing, auth) when writing activities; it costs nothing and later gives you per-tab and per-permission timelines without reworking data.

Pair with policies — and let the exception fly. Timeline visibility is a data-leak risk. Gate the relation manager/component with canView() calling a policy. Don't swallow authorization failures inside custom sources: since v1.0.2 an AuthorizationException reaches the handler and answers 403, which is what you want. Catching it and returning an empty result set reintroduces the "looks like a record with no history" failure mode the release just fixed.

Testing pattern. Assert on the activity rows in feature tests (assertDatabaseHas('activity_log', ...) / spatie's assertions), and add one thin test per resource that the timeline page renders 200 for a permitted user — plus one asserting 403 for a denied user, now that the package distinguishes the two. Don't try to assert rendered sentences broadly; they churn.

Gotchas and Tips

  • "Nothing appears" is almost always a log-name or subject filter, not a bug. Check the config's allowed log names first, then verify subject_type/subject_id are set on the rows you expect. With v1.0.2 there is now a third explanation worth ruling out: a 403. If a policy or a custom source is denying the read, you get a real 403 instead of the error panel — check the response status before you go spelunking in config.
  • CannotUpdateLockedPropertyException after upgrading. Caused by $wire.set(...) on widget state, or by a custom view/JS snippet poking properties directly. Configure through mount() or by extending timeline(). This is a deliberate hardening, not a regression; don't work around it by unlocking properties.
  • hiddenAttributes vs hidden_attributes. The v1.0.2 write-up is worth reading closely: replacing presentationOverrides from the client could drop the widget-level hiddenAttributes, but config hidden_attributes were unaffected. If a column must never appear in a timeline, put it in config, and treat widget-level hiding as cosmetic.
  • Empty properties on custom activities. If you log via activity()->log('...') without withProperties() or performedOn(), the package has nothing to build a sentence or relation from. Use performedOn($model)->event('...')->withProperties([...]) consistently, or your timeline will be a list of untitled lines.
  • updated events capture everything, including timestamps and internal flags. Without an ignore list you get updated_at churn and "remember_token changed" nonsense. Configure ignored attributes at the spatie level, not just in the view — smaller rows also mean a smaller table.
  • Dirty-data leakage. Activity properties store old and new values verbatim. Be explicit about excluding passwords, tokens, API keys, and PII you don't want in a UI with broad access. This is the most common way an activity timeline becomes a compliance problem — and the reason the #[Locked] change matters: presentation settings are now server-controlled, but the underlying properties are still as sensitive as what you logged.
  • N+1 queries. Timelines load causers and subjects per row. Enable eager loading/preloading in the config or component, and paginate — a 2-year-old tenant with 500k rows will otherwise time out before it renders. The max_per_page ceiling protects you from runaway page sizes, not from missing indexes or absent eager loading.
  • Ordering and timezones. activity_log.created_at is stored in app timezone while Filament may display per-user timezone. Confirm one convention before users start filing "the timeline is 2 hours off" tickets.
  • Locale per viewer, not per app. Because sentences come from translation keys, a French admin and an English admin should see different text from the same row. Verify app()->setLocale() is user-scoped in your panel middleware, and always provide the fallback locale entry — missing keys render raw keys in production.
  • Multi-panel and multi-tenant. Register the plugin per panel; for tenancy, scope the global timeline query to the current tenant or you'll show cross-tenant history. The record-scoped widget was never swappable, but a global timeline has no such built-in guarantee — that scoping is yours.
  • Extension points to prefer over forking: the config-driven label/morph maps, the package's resolvers, and a widget subclass overriding timeline(). If you find yourself overriding a Blade view to change wording, you've missed a config or translation key; if you're overriding one to change behaviour, you've probably missed an extension hook.
  • Small ecosystem (few stars, no dependents) means you are the QA. Pin the version in composer.json, and after upgrades check the published config diff (vendor:publish --force into a scratch dir and diff it) — config-merge changes in low-traffic packages are the usual source of silent breakage. v1.0.2 is a security release, so treat it as a priority bump rather than a routine one, and read the changelog note on max_per_page when merging your config.
  • Wrap usage in a thin app-level abstraction (one service or trait that logs activities with your conventions) rather than calling the package API from 60 places. If the package stalls or you need to swap it, only that seam changes — and it's also the single place to ensure every logged event carries the properties the timeline needs.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
adachsoft/changelog-linter
adachsoft/collection
payerurl/telegrambot-crypto-payment-instant-payout
dskripchenko/laravel-hybrid-search
riorizkyrainey/lara-ced
mehedimi/static-vel
zharp/zharp
nonsapiens/bigquery-model-sync
lgu-bayambang-ict-office/lgu-laravel-development-standards
andydefer/laravel-support
se7enxweb/flysystem
lampwick/laravel
jeslage/statamic-emojis
micahdshackelford/rmap-laravel
draftsmaninc/draftsman
square-bit/laravel-volition
himito/laravel-mailman
htinlynn/genius-helper
maco-studios/hephaestus
adachsoft/php-code-style