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:

  1. Ein StatsOverviewWidget mit den wichtigsten Kennzahlen (Aufrufe heute, diese Woche, dieser Monat),
  2. ein ChartWidget mit dem Tagesverlauf der letzten 30 Tage,
  3. eine kleine Top-Seiten-Tabelle als Widget,
  4. die Absicherung der Widgets auf Admin-Rollen.

Voraussetzungen

  • Filament 5 läuft, Admin-Panel ist unter /admin erreichbar (wie in der Roadmap: Phase 2 abgeschlossen).
  • Die visits-Tabelle existiert und ist per migrate:fresh --seed mit Demodaten befüllt (Teil 1).
  • spatie/laravel-permission ist installiert, die Rollen admin / author existieren, Gate::before für den Admin-Bypass ist konfiguriert (Roadmap: Phase 1).

Schritt 1 – StatsOverviewWidget generieren

Filament 5 stellt den Artisan-Generator für Widgets bereit:

bash
php artisan make:filament-widget VisitStatsOverview --stats-overview

Filament legt die Datei unter app/Filament/Widgets/VisitStatsOverview.php an.

Was passiert hier? --stats-overview erzeugt eine Klasse, die von Filament\Widgets\StatsOverviewWidget erbt und die Methode getStats() vorschreibt. Dort definieren wir unsere Kacheln.

Öffne die generierte Datei und ersetze den Inhalt durch:

php
<?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') statt count(*)? Unsere Tabelle speichert pro Tag eine Zeile je Seite und zählt die Aufrufe in der Spalte count hoch (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

bash
php artisan make:filament-widget VisitTrendChart --chart

Die Datei liegt unter app/Filament/Widgets/VisitTrendChart.php. Ersetze den Inhalt:

php
<?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 0 auf.


Schritt 3 – Top-Seiten-Widget generieren

bash
php artisan make:filament-widget TopVisitedPages --table

Datei: app/Filament/Widgets/TopVisitedPages.php:

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_id zurück und zeigen den Klassennamen via class_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:

php
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:

php
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:

php
use Illuminate\Support\Facades\Gate;

// In boot():
Gate::before(function ($user, $ability) {
    if ($user->hasRole('admin')) {
        return true;
    }
});

Gate::before gibt true zurück, bevor spezifische Gates geprüft werden – Admins passieren also jede Gate::allows()-Prüfung automatisch. Autorinnen ohne Admin-Rolle landen im normalen Gate-Flow und scheitern bei viewAdmin, sofern kein eigenes Policy für sie definiert ist.


Schritt 5 – Ergebnis prüfen

5a – Demodaten frisch einspielen

bash
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

  1. Melde dich als Admin unter /admin an.
  2. 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:

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