hamoda-dev/filament-activity-history

Beautiful, relation-aware activity history timelines for Filament panels.

Maintainers

Package info

github.com/hamoda-dev/filament-activity-history

pkg:composer/hamoda-dev/filament-activity-history

Transparency log

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-07-27 14:46 UTC

This package is auto-updated.

Last update: 2026-07-27 14:57:24 UTC


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

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:

  1. ->attributeLabel('price', 'Selling price') on the timeline
  2. activity.attributes.{model}.{field} — per model, for fields whose meaning differs between models
  3. activity.attributes.{field} — shared across models
  4. validation.attributes.{field} — most applications already fill this in for form errors, so those fields are localized for free
  5. the label of the matching field in the model's Filament resource form (opt in, below)
  6. 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/no strings

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_attributes drops slugs, uuids and timestamps, and an update that touched nothing else is dropped with it rather than rendered as a bare "updated". keep_empty_updates brings them back.
  • Values are tidied. Markup is stripped, whitespace collapsed, and long values trimmed to max_value_length with the full text in the row tooltip.
  • Sensitive values are masked. masked_attributes lists the change but replaces both values with mask.
  • Milestones stand out. created, deleted and restored get a circled, coloured icon; ordinary updates get a small node, so the eye follows the shape of the timeline. Configure with milestone_events, icons and colors.

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 name was 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 from validation.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 => false re-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.