There's no official UI viewer for the popular spatie/laravel-activitylog package, so I decided to build a simple Filament page for it. No plugins required!
You can just generate a custom Filament page:
php artisan make:filament-page ActivityLog
It appears on the sidebar menu, and inside you can create a Table with the data using Activity
Eloquent model from the package. That's basically it!
I've tried to build such a table for one of our Premium Projects Repair Salon CRM, seeded some fake data, and ended up with this:
Here's the full code of my version of that page:
app/Filament/Pages/ActivityLog.php
<?php namespace App\Filament\Pages; use BackedEnum;use Filament\Actions\Action;use Filament\Actions\Concerns\InteractsWithActions;use Filament\Actions\Contracts\HasActions;use Filament\Pages\Page;use Filament\Schemas\Concerns\InteractsWithSchemas;use Filament\Schemas\Contracts\HasSchemas;use Filament\Support\Icons\Heroicon;use Filament\Tables\Columns\TextColumn;use Filament\Tables\Concerns\InteractsWithTable;use Filament\Tables\Contracts\HasTable;use Filament\Tables\Filters\SelectFilter;use Filament\Tables\Table;use Illuminate\Support\HtmlString;use Spatie\Activitylog\Models\Activity as ActivityLogModel; class ActivityLog extends Page implements HasActions, HasSchemas, HasTable{ use InteractsWithActions; use InteractsWithSchemas; use InteractsWithTable; protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack; protected string $view = 'filament.pages.activity-log'; protected static ?string $title = 'Activity Log'; protected static ?string $slug = 'activities'; public function table(Table $table): Table { return $table ->query(ActivityLogModel::query()->with(['causer', 'subject'])->latest()) ->columns([ TextColumn::make('created_at') ->label('Time') ->dateTime('M j, H:i') ->sortable(), TextColumn::make('event') ->badge() ->color(fn (?string $state): string => match ($state) { 'created' => 'success', 'updated' => 'warning', 'deleted' => 'danger', default => 'gray', }) ->placeholder('N/A'), TextColumn::make('subject_name') ->label('Subject') ->getStateUsing(function (ActivityLogModel $record): ?string { if (! $record->subject_type) { return null; } $subjectType = class_basename($record->subject_type); if (! $record->subject) { return "{$subjectType} ID {$record->subject_id}"; } // Resolve the field name from protected/public $activitySubjectName // and show its attribute if (property_exists($record->subject, 'activitySubjectName')) { $fieldName = (function () { return $this->activitySubjectName; })->call($record->subject); $value = $record->subject->getAttribute($fieldName); if (filled($value)) { return "{$subjectType}: {$value}"; } } // Fallback to checking for 'name' property if (isset($record->subject->name)) { return "{$subjectType}: {$record->subject->name}"; } // Final fallback return "{$subjectType} ID {$record->subject_id}"; }) ->placeholder('N/A'), TextColumn::make('causer_name') ->label('Causer') ->getStateUsing(function (ActivityLogModel $record): ?string { if (!$record->causer) { return 'System'; } // For User models, use the name accessor if ($record->causer instanceof \App\Models\User) { return $record->causer->name; } // Fallback for other models return $record->causer->name ?? "#{$record->causer_id}"; }) ->placeholder('System') ->sortable(), TextColumn::make('description') ->limit(50) ->tooltip(function (TextColumn $column): ?string { $state = $column->getState(); if (strlen($state) <= $column->getCharacterLimit()) { return null; } return $state; }), ]) ->filters([ SelectFilter::make('subject_type') ->label('Subject Type') ->options(fn (): array => ActivityLogModel::query() ->whereNotNull('subject_type') ->select('subject_type') ->distinct() ->pluck('subject_type') ->filter() ->mapWithKeys(fn (string $type): array => [$type => class_basename($type)]) ->toArray() ) ->searchable() ->preload(), SelectFilter::make('causer_id') ->label('Causer User') ->options(fn (): array => \App\Models\User::query() ->whereIn('id', ActivityLogModel::query() ->where('causer_type', \App\Models\User::class) ->whereNotNull('causer_id') ->select('causer_id') ->distinct() ) ->orderBy('first_name') ->get(['id', 'first_name', 'last_name']) ->pluck('name', 'id') ->toArray() ) ->searchable() ->preload() ->query(function ($query, $state): void { if (filled($state['value'])) { $query ->where('causer_type', \App\Models\User::class) ->where('causer_id', $state['value']); } }), ]) ->recordActions([ Action::make('viewProperties') ->label('View Properties') ->modalHeading('Properties') ->modalContent(function (ActivityLogModel $record): HtmlString { $state = $record->properties; if (empty($state)) { return new HtmlString('<div class="text-gray-500">N/A</div>'); } $properties = is_string($state) ? json_decode($state, true) : $state; if ($properties === null && json_last_error() !== JSON_ERROR_NONE) { $properties = $state; } $json = json_encode($properties, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); return new HtmlString('<div class="text-sm whitespace-pre-wrap font-mono">'.e((string) $json).'</div>'); }) ->modalSubmitAction(false) ->modalCancelAction(fn (Action $action) => $action->label('Close')->color('primary')), ]) ->defaultSort('created_at', 'desc') ->paginated([100]); }}
A few explanations on the main points:
ActivityLogModel::query()->with(['causer', 'subject']
with Eager Loading, to avoid N+1 Query problemgetStateUsing()
checks if related Eloquent model has a property $activitySubjectName
, specifying which DB column to showSo, this is my "quick" version. From here, you can customize columns and filters however you want.
This tutorial's code comes from our Project Example Repair Salon CRM
A few of our Premium Examples: