Bei Pixelklicker zählen wir Seitenaufrufe bewusst anonym und fassen sie pro Tag zusammen (Tagesrollup). So sehen wir Trends, ohne personenbezogene Daten zu sammeln – und das Dashboard bleibt schlank. In diesem Tutorial baust du genau das nach: eine Visit-Erfassung mit Tages-Aggregation und ein StatsOverview-Widget in Filament.

Hinweis: Dieses Tutorial zeigt einen praxisnahen, an der Pixelklicker-Architektur (Laravel, Filament, Visit-Modell, StatsOverview-Widget) orientierten Aufbau. Wo die interne Implementierung im Detail abweichen kann, weise ich darauf hin.

1. Ziel & Lernergebnis

Am Ende hast du:

  • eine Tabelle, die Aufrufe pro Tag aggregiert speichert (statt jede einzelne Anfrage zu loggen),
  • ein Middleware-/Service-gestütztes Tracking, das anonym ist (keine IP-Speicherung, keine Cookies),
  • ein Filament-StatsOverview-Widget, das die Kennzahlen (z. B. heutige Aufrufe, Summe, Trend) anzeigt.

Du kannst danach das Muster auf andere zählbare Ereignisse (z. B. Demo-Klicks) übertragen.

2. Voraussetzungen

  • Ein lauffähiges Laravel-Projekt mit Filament-Admin-Panel unter /admin.
  • Grundkenntnisse in Migrations, Eloquent-Modellen und Artisan.
  • Filament im Projekt installiert und Zugriff auf das Dashboard.

3. Schritte

Schritt 1 – Datenmodell für den Tagesrollup anlegen

Wir speichern pro Tag und pro Ziel (z. B. eine Seite oder ein Artikel) eine Zeile mit einem Zähler. Das hält die Tabelle klein und Auswertungen schnell.

bash
php artisan make:model Visit -m

In der erzeugten Migration definieren wir die Rollup-Struktur:

php
// database/migrations/xxxx_xx_xx_create_visits_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('visits', function (Blueprint $table) {
            $table->id();
            $table->date('day');                 // der Tag (Rollup-Schlüssel)
            $table->string('trackable_type')->nullable();
            $table->unsignedBigInteger('trackable_id')->nullable();
            $table->unsignedBigInteger('count')->default(0);
            $table->timestamps();

            // pro Tag + Ziel nur eine Zeile
            $table->unique(['day', 'trackable_type', 'trackable_id']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('visits');
    }
};

Warum so? Das unique-Constraint garantiert genau eine Zeile pro Tag und Ziel. Den Zähler erhöhen wir atomar – das ist der Kern des „Tagesrollups". Über trackable_type/trackable_id (eine polymorphe Verknüpfung) lassen sich Seiten, Artikel oder Projekte tracken. Brauchst du nur globale Aufrufe, kannst du die trackable_*-Spalten weglassen.

Schritt 2 – Modell vorbereiten

php
// app/Models/Visit.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;

class Visit extends Model
{
    protected $fillable = ['day', 'trackable_type', 'trackable_id', 'count'];

    protected $casts = [
        'day' => 'date',
        'count' => 'integer',
    ];

    public function trackable(): MorphTo
    {
        return $this->morphTo();
    }
}

Warum? Das date-Cast sorgt für saubere Tagesvergleiche, das integer-Cast für korrekte Summen.

Schritt 3 – Tracking-Service mit atomarem Increment

Wir kapseln das Hochzählen in einer kleinen, wiederverwendbaren Methode. So bleibt die Logik an einer Stelle.

php
// app/Services/VisitTracker.php
namespace App\Services;

use App\Models\Visit;
use Illuminate\Database\Eloquent\Model;

class VisitTracker
{
    public function record(?Model $trackable = null): void
    {
        $visit = Visit::firstOrCreate(
            [
                'day' => now()->toDateString(),
                'trackable_type' => $trackable?->getMorphClass(),
                'trackable_id' => $trackable?->getKey(),
            ],
            ['count' => 0],
        );

        // atomar erhöhen, vermeidet Race Conditions
        $visit->increment('count');
    }
}

Warum increment()? Es führt ein UPDATE ... SET count = count + 1 aus. Damit gehen bei gleichzeitigen Anfragen keine Zählungen verloren. firstOrCreate legt die Tageszeile bei Bedarf an.

Schritt 4 – Aufrufe anonym erfassen (Middleware)

Damit nichts Personenbezogenes gespeichert wird, übergeben wir keine IP, keinen User-Agent und setzen keinen Cookie. Wir erhöhen schlicht den Tageszähler.

bash
php artisan make:middleware TrackPageView
php
// app/Http/Middleware/TrackPageView.php
namespace App\Http\Middleware;

use App\Services\VisitTracker;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class TrackPageView
{
    public function __construct(private VisitTracker $tracker) {}

    public function handle(Request $request, Closure $next): Response
    {
        $response = $next($request);

        // nur erfolgreiche GET-Seitenaufrufe zählen
        if ($request->isMethod('GET') && $response->getStatusCode() < 400) {
            $this->tracker->record();
        }

        return $response;
    }
}

Registriere die Middleware für deine Frontend-Routen (z. B. in bootstrap/app.php über ->withMiddleware() oder einer Route-Group). Bei Pixelklicker wird das anonyme Tracking nur fürs öffentliche Frontend genutzt – das Admin-Panel solltest du ausnehmen.

Warum nach $next($request)? So zählen wir erst, wenn die Antwort steht, und können Fehlerseiten (Status ≥ 400) ausschließen.

Schritt 5 – Gezieltes Tracking auf Detailseiten (optional)

Wenn du z. B. Artikelaufrufe getrennt zählen willst, rufst du den Service im Controller mit dem konkreten Modell auf:

php
// im Artikel-Controller
public function show(Article $article, VisitTracker $tracker)
{
    $tracker->record($article);

    return view('articles.show', compact('article'));
}

Warum? So landet die Zählung in der polymorphen Spalte und du kannst später „Top-Artikel" auswerten.

Schritt 6 – Das Filament-StatsOverview-Widget bauen

Jetzt visualisieren wir die Zahlen. Erzeuge ein Widget:

bash
php artisan make:filament-widget VisitsOverview --stats-overview
php
// app/Filament/Widgets/VisitsOverview.php
namespace App\Filament\Widgets;

use App\Models\Visit;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;

class VisitsOverview extends BaseWidget
{
    protected function getStats(): array
    {
        $today = Visit::whereDate('day', now()->toDateString())->sum('count');
        $last7 = Visit::whereDate('day', '>=', now()->subDays(6)->toDateString())->sum('count');
        $total = Visit::sum('count');

        return [
            Stat::make('Aufrufe heute', number_format($today, 0, ',', '.'))
                ->description('Anonyme Seitenaufrufe')
                ->color('success'),

            Stat::make('Letzte 7 Tage', number_format($last7, 0, ',', '.'))
                ->description('Summe der Tagesrollups'),

            Stat::make('Gesamt', number_format($total, 0, ',', '.'))
                ->description('Alle erfassten Aufrufe'),
        ];
    }
}

Warum sum('count')? Da wir pro Tag aggregieren, summieren wir nur die Tageszeilen – das ist deutlich günstiger, als einzelne Events zu zählen. Das Widget wird vom Dashboard automatisch erkannt; je nach Konfiguration registrierst du es in deiner Dashboard-Page oder es wird über Auto-Discovery eingebunden.

Schritt 7 – Optional: Mini-Trend mit Chart

Stat unterstützt einen kleinen Sparkline-Chart über ->chart([...]). Du kannst die Tageswerte der letzten Woche übergeben:

php
$chart = Visit::whereDate('day', '>=', now()->subDays(6)->toDateString())
    ->selectRaw('day, SUM(count) as total')
    ->groupBy('day')
    ->orderBy('day')
    ->pluck('total')
    ->all();

// ...
Stat::make('Aufrufe heute', $today)->chart($chart);

Warum? Der kleine Verlauf gibt sofort ein Gefühl für den Trend, ohne ein vollwertiges Chart-Widget.

4. Ergebnis prüfen

  1. Migration ausführen:

    bash
    php artisan migrate
    
  2. Eine Frontend-Seite mehrfach im Browser aufrufen.

  3. Über Tinker prüfen, ob genau eine Tageszeile mit hochgezähltem count existiert:

    bash
    php artisan tinker
    >>> \App\Models\Visit::whereDate('day', now())->get(['day','count']);
    
  4. Im Filament-Dashboard unter /admin sollte das VisitsOverview-Widget die heutigen Aufrufe und die Summen anzeigen.

Wenn die Zahl pro Reload um 1 steigt und es keine zweite Zeile für denselben Tag gibt, funktioniert der Tagesrollup.

5. Stolpersteine / Troubleshooting

  • Mehrere Zeilen pro Tag: Fehlt das unique-Constraint oder weicht der Tageswert (Zeitzone!) ab. Stelle sicher, dass now()->toDateString() konsistent dieselbe Zeitzone nutzt (config/app.phptimezone).
  • Race Conditions / verlorene Zählungen: Nicht manuell count = count + 1 in PHP rechnen und speichern – immer increment() verwenden, das atomar im SQL erfolgt.
  • Admin-Aufrufe werden mitgezählt: Middleware nur an Frontend-Routen hängen, das Panel ausnehmen.
  • Bots/Assets blähen die Zahlen auf: Nur GET mit Status < 400 zählen; bei Bedarf zusätzlich auf HTML-Antworten einschränken oder Asset-Routen ausschließen.
  • Widget erscheint nicht: Prüfe, ob das Widget im Dashboard registriert bzw. per Discovery erkannt wird, und ob dein Benutzer Zugriff auf das Panel hat.
  • Datenschutz: Speichere weder IP noch User-Agent. Sobald du diese erfasst, ist es nicht mehr „anonym".

6. Fazit & nächste Schritte

Du hast ein datensparsames, anonymes Aufruf-Tracking mit Tagesrollup gebaut und die Kennzahlen direkt im Filament-Dashboard sichtbar gemacht. Das Muster ist robust (atomares Increment), günstig (Aggregation statt Event-Flut) und DSGVO-freundlich.

Sinnvolle nächste Schritte:

  • Aufrufe über Zeit als Chart-Widget und Top-Projekte/Artikel auswerten.
  • Demo-Klick-Statistik nach demselben Prinzip erfassen.

So wächst aus dem schlanken Zähler nach und nach ein vollwertiges Analytics-Dashboard.