DEV3 Technologies home

Filament read-only activity log table with diff modal

An audit trail resource for Filament 4 on top of the activity log package: filters by event and user, with a modal showing old and new values.

FilamentTablesfilamentaudit-logactivitytable

Code

<?php

namespace App\Filament\Resources\Activities;

use App\Filament\Resources\Activities\Pages\ListActivities;
use BackedEnum;
use Filament\Actions\ViewAction;
use Filament\Infolists\Components\KeyValueEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Resources\Resource;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Spatie\Activitylog\Models\Activity;

class ActivityResource extends Resource
{
    protected static ?string $model = Activity::class;

    protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedClipboardDocumentList;

    protected static ?string $navigationLabel = 'Activity log';

    // Read-only: an audit trail must never be edited from the UI.
    public static function canCreate(): bool
    {
        return false;
    }

    public static function canEdit($record): bool
    {
        return false;
    }

    public static function canDelete($record): bool
    {
        return false;
    }

    public static function table(Table $table): Table
    {
        return $table
            ->defaultSort('created_at', 'desc')
            ->modifyQueryUsing(fn ($query) => $query->with(['causer', 'subject']))
            ->columns([
                TextColumn::make('created_at')->dateTime()->sortable(),
                TextColumn::make('causer.name')->placeholder('System')->searchable(),
                TextColumn::make('event')->badge()->color(fn (?string $state) => match ($state) {
                    'created' => 'success',
                    'deleted' => 'danger',
                    default => 'info',
                }),
                TextColumn::make('subject_type')
                    ->label('Record')
                    ->formatStateUsing(fn ($state, Activity $record) => class_basename($state).' #'.$record->subject_id),
                TextColumn::make('description')->limit(50)->searchable(),
            ])
            ->filters([
                SelectFilter::make('event')->options(['created' => 'Created', 'updated' => 'Updated', 'deleted' => 'Deleted']),
                SelectFilter::make('causer_id')->label('User')->relationship('causer', 'name')->searchable()->preload(),
            ])
            ->recordActions([
                ViewAction::make()->modalHeading('Change details')->schema([
                    TextEntry::make('description'),
                    KeyValueEntry::make('properties.old')->label('Before')->visible(fn ($record) => filled($record->properties['old'] ?? null)),
                    KeyValueEntry::make('properties.attributes')->label('After')->visible(fn ($record) => filled($record->properties['attributes'] ?? null)),
                ]),
            ]);
    }

    public static function getPages(): array
    {
        return ['index' => ListActivities::route('/')];
    }
}

Needs: filament/filament@^4.0, spatie/laravel-activitylog@^4.8

How it works

What it solves

Teams need to answer "who changed this, and when?". The activity log package records the events; this resource gives support and admins a safe screen for reading them, with enough filtering to find one change in thousands.

How it works

The resource wraps the package's Activity model. Three can* overrides remove the create, edit and delete paths, so even a user with broad permissions cannot rewrite history from the UI. The list page returns no header actions for the same reason.

The table sorts newest first and eager loads causer and subject in modifyQueryUsing, avoiding a query per row. The user column falls back to "System" with placeholder() for jobs and console commands, which have no causer. The record column turns the stored class name into a readable "Order #42" with class_basename.

Filters narrow by event type and by user through the relationship. The ViewAction opens a modal whose schema shows the description and two KeyValueEntry blocks, "Before" and "After", read from the JSON properties. Each block is hidden when empty, so created and deleted events show only the relevant side.

Adapting it

Add a date range filter, or a subject type filter built from the distinct values. Limit the resource to the current tenant by constraining getEloquentQuery(). Hide sensitive attributes using the package's logOnly or logExcept settings when you record changes, not when you display them.

Pitfalls

  • Only models that use the LogsActivity trait produce rows.
  • A delete permission override in a policy does not stop bulk actions you add yourself; leave bulk actions out.
  • Large tables need an index on created_at and causer_id.

How to use it

  1. Install the package: composer require spatie/laravel-activitylog and publish and run its migrations.
  2. Add use LogsActivity; and getActivitylogOptions() to the models you want audited.
  3. Copy the resource and list page into app/Filament/Resources/Activities/.
  4. Restrict who can open it with a policy or canViewAny() on the resource.
  5. Open Activity log in the panel and click a row action to see before and after values.

Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.

Related snippets