Teil 4 – Filament-Dashboard-Widget: Aufrufe visualisieren
Einordnung
In den ersten drei Teilen haben wir die visits-Tabelle angelegt (Teil 1), die datenschutzfreundliche RecordVisitAction per Tages-Rollup implementiert (Teil 2) und sie in die Livewire-Komponenten des öffentlichen Frontends eingebunden (Teil 3). Die Daten werden also zuverlässig gesammelt – jetzt holen wir sie ins Filament-Admin-Dashboard und machen sie sichtbar.
In diesem abschließenden Teil erstellen wir:
- Ein StatsOverviewWidget mit den wichtigsten Kennzahlen (Aufrufe heute, diese Woche, dieser Monat),
- ein ChartWidget mit dem Tagesverlauf der letzten 30 Tage,
- eine kleine Top-Seiten-Tabelle als Widget,
- die Absicherung der Widgets auf Admin-Rollen.
Voraussetzungen
- Filament 5 läuft, Admin-Panel ist unter
/adminerreichbar (wie in der Roadmap: Phase 2 abgeschlossen). - Die
visits-Tabelle existiert und ist permigrate:fresh --seedmit Demodaten befüllt (Teil 1). spatie/laravel-permissionist installiert, die Rollenadmin/authorexistieren,Gate::beforefür den Admin-Bypass ist konfiguriert (Roadmap: Phase 1).
Schritt 1 – StatsOverviewWidget generieren
Filament 5 stellt den Artisan-Generator für Widgets bereit:
php artisan make:filament-widget VisitStatsOverview --stats-overview
Filament legt die Datei unter app/Filament/Widgets/VisitStatsOverview.php an.
Was passiert hier?
--stats-overviewerzeugt eine Klasse, die vonFilament\Widgets\StatsOverviewWidgeterbt und die MethodegetStats()vorschreibt. Dort definieren wir unsere Kacheln.
Öffne die generierte Datei und ersetze den Inhalt durch:
<?php namespace App\Filament\Widgets; use App\Models\Visit; use Filament\Widgets\StatsOverviewWidget as BaseWidget; use Filament\Widgets\StatsOverviewWidget\Stat; use Illuminate\Support\Facades\Gate; class VisitStatsOverview extends BaseWidget { // Widget nur für Admins sichtbar (siehe Schritt 4) public static function canView(): bool { return Gate::allows('viewAdmin', auth()->user()); } protected function getStats(): array { $today = now()->toDateString(); $weekStart = now()->startOfWeek()->toDateString(); $monthStart = now()->startOfMonth()->toDateString(); $visitsToday = Visit::whereDate('date', $today) ->sum('count'); $visitsThisWeek = Visit::whereBetween('date', [$weekStart, $today]) ->sum('count'); $visitsThisMonth = Visit::whereBetween('date', [$monthStart, $today]) ->sum('count'); return [ Stat::make('Aufrufe heute', number_format((int) $visitsToday, 0, ',', '.')) ->description('Summe aller Einträge für ' . now()->format('d.m.Y')) ->color('primary'), Stat::make('Aufrufe diese Woche', number_format((int) $visitsThisWeek, 0, ',', '.')) ->description('ab ' . now()->startOfWeek()->format('d.m.Y')) ->color('success'), Stat::make('Aufrufe diesen Monat', number_format((int) $visitsThisMonth, 0, ',', '.')) ->description(now()->format('F Y')) ->color('info'), ]; } }
Warum
sum('count')stattcount(*)? Unsere Tabelle speichert pro Tag eine Zeile je Seite und zählt die Aufrufe in der Spaltecounthoch (Tages-Rollup aus Teil 2).count(*)würde lediglich die Anzahl der Zeilen liefern, nicht die tatsächlichen Seitenaufrufe.
Schritt 2 – ChartWidget für den 30-Tage-Verlauf generieren
php artisan make:filament-widget VisitTrendChart --chart
Die Datei liegt unter app/Filament/Widgets/VisitTrendChart.php. Ersetze den Inhalt:
<?php namespace App\Filament\Widgets; use App\Models\Visit; use Filament\Widgets\ChartWidget; use Illuminate\Support\Facades\Gate; class VisitTrendChart extends ChartWidget { protected static ?string $heading = 'Seitenaufrufe – letzte 30 Tage'; protected static ?int $sort = 2; public static function canView(): bool { return Gate::allows('viewAdmin', auth()->user()); } protected function getType(): string { return 'line'; } protected function getData(): array { $days = 30; $start = now()->subDays($days - 1)->startOfDay(); // Summe aller Aufrufe je Kalendertag $rows = Visit::selectRaw('date, SUM(count) as total') ->where('date', '>=', $start->toDateString()) ->groupBy('date') ->orderBy('date') ->pluck('total', 'date'); // ['2025-07-01' => 42, ...] // Lückenlosen Datumsbereich aufbauen (fehlende Tage → 0) $labels = []; $data = []; for ($i = $days - 1; $i >= 0; $i--) { $date = now()->subDays($i)->toDateString(); $labels[] = now()->subDays($i)->format('d.m.'); $data[] = (int) ($rows[$date] ?? 0); } return [ 'datasets' => [ [ 'label' => 'Aufrufe', 'data' => $data, 'borderColor' => '#6366f1', 'backgroundColor' => 'rgba(99,102,241,0.1)', 'fill' => true, 'tension' => 0.3, ], ], 'labels' => $labels, ]; } }
Warum die Lückenfüllung? Tage ohne einen einzigen Aufruf haben keinen Eintrag in der Datenbank. Ohne die explizite Schleife würde Chart.js die fehlenden Datenpunkte überspringen und die X-Achse verzerren. Wir füllen sie daher mit
0auf.
Schritt 3 – Top-Seiten-Widget generieren
php artisan make:filament-widget TopVisitedPages --table
Datei: app/Filament/Widgets/TopVisitedPages.php:
<?php namespace App\Filament\Widgets; use App\Models\Visit; use Filament\Tables; use Filament\Tables\Table; use Filament\Widgets\TableWidget as BaseWidget; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\Gate; class TopVisitedPages extends BaseWidget { protected static ?string $heading = 'Top-Seiten (letzte 30 Tage)'; protected static ?int $sort = 3; protected int | string | array $columnSpan = 'full'; public static function canView(): bool { return Gate::allows('viewAdmin', auth()->user()); } public function table(Table $table): Table { return $table ->query( // Polymorphe Herkunft als lesbarer Typ-String, // Summe über die letzten 30 Tage Visit::selectRaw('visitable_type, visitable_id, SUM(count) as total_visits') ->where('date', '>=', now()->subDays(29)->toDateString()) ->groupBy('visitable_type', 'visitable_id') ->orderByDesc('total_visits') ->limit(10) ) ->columns([ Tables\Columns\TextColumn::make('visitable_type') ->label('Typ') ->formatStateUsing(fn (string $state): string => class_basename($state)) ->badge(), Tables\Columns\TextColumn::make('visitable_id') ->label('ID'), Tables\Columns\TextColumn::make('total_visits') ->label('Aufrufe (30 Tage)') ->numeric() ->sortable(), ]) ->paginated(false); } }
Hinweis zur polymorphen Relation: Wir greifen hier bewusst auf die rohen Spalten
visitable_type/visitable_idzurück und zeigen den Klassennamen viaclass_basename()lesbar an. Ein vollständiges Eager-Loading des polymorphen Modells würde N+1-Abfragen auslösen und den Widget-Scope sprengen – für eine detailliertere Ansicht bietet sich eine eigene Filament-Resource an.
Schritt 4 – Widgets im Dashboard registrieren und absichern
4a – Dashboard-Widgets registrieren
In Filament 5 werden Widgets am Dashboard-Panel registriert. Öffne deine Panel-Provider-Datei (app/Providers/Filament/AdminPanelProvider.php) und füge die drei Widget-Klassen im widgets()-Array hinzu:
use App\Filament\Widgets\VisitStatsOverview; use App\Filament\Widgets\VisitTrendChart; use App\Filament\Widgets\TopVisitedPages; // ... ->widgets([ // ggf. bereits vorhandene Widgets behalten: \Filament\Widgets\AccountWidget::class, // Visit-Tracking-Widgets: VisitStatsOverview::class, VisitTrendChart::class, TopVisitedPages::class, ])
Die Reihenfolge im Array bestimmt die Standard-Sortierung. Die
protected static ?int $sort-Properties in den Widget-Klassen (Schritte 2 & 3) überschreiben diese bei Bedarf.
4b – Absicherung per Gate
Alle drei Widgets enthalten bereits:
public static function canView(): bool { return Gate::allows('viewAdmin', auth()->user()); }
Das viewAdmin-Gate wird in eurer Anwendung über Gate::before für die Rolle admin freigegeben (wie in Phase 1 der Roadmap eingerichtet). Autorinnen mit der Rolle author sehen die Visit-Widgets damit nicht – sie können zwar Artikel erstellen, haben aber keinen Einblick in Nutzungsstatistiken.
Stelle sicher, dass der Gate-Callback in app/Providers/AuthServiceProvider.php (oder der entsprechenden Boot-Methode) so aussieht:
use Illuminate\Support\Facades\Gate; // In boot(): Gate::before(function ($user, $ability) { if ($user->hasRole('admin')) { return true; } });
Gate::beforegibttruezurück, bevor spezifische Gates geprüft werden – Admins passieren also jedeGate::allows()-Prüfung automatisch. Autorinnen ohne Admin-Rolle landen im normalen Gate-Flow und scheitern beiviewAdmin, sofern kein eigenes Policy für sie definiert ist.
Schritt 5 – Ergebnis prüfen
5a – Demodaten frisch einspielen
php artisan migrate:fresh --seed
Der VisitSeeder (aus Teil 1) füllt die Tabelle mit realistischen Tageswerten für die letzten Wochen.
5b – Dashboard aufrufen
- Melde dich als Admin unter
/adminan. - Das Dashboard sollte jetzt zeigen:
- Drei Stat-Kacheln (Heute / Woche / Monat) mit Zahlenwerten aus den Seeder-Daten.
- Liniendiagramm mit dem 30-Tage-Verlauf – auch Tage ohne Einträge erscheinen als
0. - Top-10-Tabelle der meistaufgerufenen Seiten nach Typ und ID.
5c – Rollenprüfung testen
Melde dich alternativ als Autor an: Die drei Visit-Widgets sollten nicht erscheinen. Nur die standard Filament-Widgets (z. B. AccountWidget) bleiben sichtbar.
Stolpersteine
Leere Datenlage (frische Installation)
Wenn noch keine Aufrufe in der Tabelle sind (z. B. direkt nach migrate:fresh ohne Seeder), liefern sum() und pluck() leere Ergebnisse – das ist kein Fehler. Die Stat-Kacheln zeigen 0, das Chart ist eine flache Linie. Der ?? 0-Fallback in getData() stellt sicher, dass kein PHP-Fehler durch fehlende Array-Schlüssel entsteht.
canView() wird nicht ausgewertet
Falls Widgets trotz falscher Rolle angezeigt werden: Prüfe, ob canView() als public static deklariert ist. Eine nicht-statische oder private Methode wird von Filament ignoriert, das Widget ist dann für alle sichtbar.
Chart bleibt leer trotz vorhandener Daten
Kontrolliere, ob die date-Spalte in der Datenbank als DATE-Typ gespeichert ist (nicht DATETIME oder TIMESTAMP). Der Vergleich where('date', '>=', ...) mit einem toDateString()-Wert funktioniert nur zuverlässig bei reinen Datumsspalten. Die Migration aus Teil 1 legt sie korrekt als $table->date('date') an.
Polymorphe Typen enthalten den vollständigen Namespace
visitable_type enthält in der Datenbank den vollen Klassennamen (z. B. App\Models\Article). class_basename() in der Tabellenspalte zeigt daher nur Article an – lesbar und ausreichend für das Dashboard. Wer die Original-Strings sehen möchte, kann class_basename() einfach weglassen.
Widgets erscheinen nicht nach Registrierung
Filament cached die Panel-Konfiguration in der Produktion. Nach jeder Änderung am Panel-Provider:
php artisan filament:cache-components # oder bei Bedarf: php artisan optimize:clear
Ergebnis
Das Filament-Dashboard zeigt jetzt drei aufeinander abgestimmte Widgets, die die aggregierten Daten aus der visits-Tabelle übersichtlich visualisieren – abgesichert auf die Admin-Rolle, ohne Performance-Einbußen durch N+1-Abfragen und robust gegenüber Tagen ohne Einträge.
Abschluss der Serie
Mit diesem vierten Teil ist die Serie „Anonymes Visit-Tracking in Laravel & Filament: Tages-Rollup ohne Nutzeridentifikation" vollständig:
| Teil | Thema |
|---|---|
| 1 | Datenmodell & Migration – visits-Tabelle, Model, Factory, Seeder |
| 2 | Tracking-Logik – RecordVisitAction mit upsert/increment |
| 3 | Livewire-Integration – Tracking im Frontend auslösen |
| 4 | Filament-Dashboard-Widget – Aufrufe visualisieren |
Das Tracking läuft vollständig ohne Nutzeridentifikation, ist DSGVO-konform, performant durch den Tages-Rollup und jetzt auch im Admin-Panel gut sichtbar. Als nächsten Ausbauschritt empfiehlt die Roadmap (Phase 8) eine detailliertere Analyse: Aufrufe über Zeit je Modell-Typ, Demo-Klick-Statistiken und eine eigene Analytics-Resource.