Teil 2 – Tracking-Logik: Anonymen Aufruf per Tages-Rollup speichern
In Teil 1 haben wir die visits-Tabelle mit ihrer upsert-freundlichen Struktur (visitable_type, visitable_id, visited_on, count) angelegt und das Eloquent-Model Visit erstellt. Jetzt bauen wir die Logik, die einen tatsächlichen Seitenaufruf in genau diesen Datensatz überführt – anonym, DSGVO-konform und ohne Performance-Probleme.
Hintergrund: Insert-per-Request vs. Tages-Rollup
Bevor wir Code schreiben, lohnt ein kurzer Vergleich zweier Tracking-Strategien:
| Ansatz | Vorgehen | Problem |
|---|---|---|
| Insert-per-Request | Jeder Aufruf → neue Zeile mit Timestamp | Tabelle wächst unbegrenzt; Auswertungen sind teuer; IP/Session-ID verleitet zur Speicherung → DSGVO-Problem |
| Tages-Rollup (unser Ansatz) | Pro Tag + Seite genau ein Datensatz, count wird hochgezählt |
Tabelle bleibt klein; keine Rückschlüsse auf Einzelpersonen möglich; klar DSGVO-konform |
Das Tages-Rollup ist datenschutzrechtlich der sauberere Weg: Selbst wenn jemand Zugriff auf die Datenbank erhielte, ließe sich daraus kein Nutzungsverhalten einer Einzelperson rekonstruieren. Es gibt schlicht keine personenbezogenen Merkmale – keine IP, keine Session-ID, keinen User-Agent.
Schritt 1 – Die RecordVisitAction anlegen
Wir kapseln die gesamte Tracking-Logik in einer einzelnen, wiederverwendbaren Action-Klasse. Das macht sie testbar und unabhängig von Livewire oder Middleware.
php artisan make:class App/Actions/RecordVisitAction
Öffne die neu erstellte Datei und ersetze ihren Inhalt vollständig:
<?php // app/Actions/RecordVisitAction.php declare(strict_types=1); namespace App\Actions; use App\Models\Visit; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\DB; final class RecordVisitAction { /** * Zählt einen Aufruf für das übergebene Modell anonym als Tages-Rollup. * Es werden keinerlei personenbezogene Daten (IP, Session, User-Agent) gespeichert. */ public function execute(Model $visitable): void { // Primärschlüssel des trackbaren Modells sicherstellen $visitableId = $visitable->getKey(); if ($visitableId === null) { return; // Nicht persistiertes Modell – nichts zu tracken } Visit::upsert( [ [ 'visitable_type' => $visitable->getMorphClass(), 'visitable_id' => $visitableId, 'visited_on' => now()->toDateString(), // nur das Datum, nie eine Uhrzeit 'count' => 1, ], ], uniqueBy: ['visitable_type', 'visitable_id', 'visited_on'], update: ['count' => DB::raw('count + 1')], ); } }
Was passiert hier?
upsert()versucht zunächst einINSERT. Existiert für die Kombination(visitable_type, visitable_id, visited_on)bereits ein Datensatz (dank des Unique-Index aus Teil 1), greift stattdessen dasON DUPLICATE KEY UPDATE/ON CONFLICT DO UPDATEdes jeweiligen Datenbanktreibers.DB::raw('count + 1')inkrementiert den Zähler atomar auf Datenbankebene – kein Race-Condition-Problem bei parallelen Requests.now()->toDateString()liefert nurYYYY-MM-DD. Uhrzeiten werden bewusst verworfen.- Kein
request()->ip(), keinrequest()->userAgent(), keinesession()->getId()– die Methode hat keinen Zugriff auf die aktuelle HTTP-Anfrage.
Schritt 2 – Morphable-Trait für trackbare Modelle (optional, empfohlen)
Damit Article, Project oder zukünftige Modelle die Beziehung zu Visit sauber ausdrücken können, fügen wir einen kleinen Trait hinzu.
php artisan make:class App/Concerns/HasVisits --invokable
Hinweis:
--invokablewird hier nur verwendet, damit Artisan die Datei anlegt. Wir ersetzen den Inhalt vollständig – es wird kein Invokable-Interface benötigt.
<?php // app/Concerns/HasVisits.php declare(strict_types=1); namespace App\Concerns; use App\Models\Visit; use Illuminate\Database\Eloquent\Relations\MorphMany; trait HasVisits { public function visits(): MorphMany { return $this->morphMany(Visit::class, 'visitable'); } /** * Gesamtaufrufe über alle Tage hinweg. */ public function totalVisits(): int { return (int) $this->visits()->sum('count'); } /** * Aufrufe am heutigen Tag. */ public function todayVisits(): int { return (int) $this->visits() ->whereDate('visited_on', today()) ->sum('count'); } }
Binde den Trait in die relevanten Modelle ein:
<?php // app/Models/Article.php (Ausschnitt) use App\Concerns\HasVisits; class Article extends Model { use HasVisits; // ... restliche Klasse }
<?php // app/Models/Project.php (Ausschnitt) use App\Concerns\HasVisits; class Project extends Model { use HasVisits; // ... restliche Klasse }
Schritt 3 – Sicherstellen, dass das Visit-Model die Morph-Map kennt
Damit visitable_type in der Datenbank kurze, stabile Bezeichner speichert (statt voller Klassennamenpfade), registrieren wir eine Morph-Map. Das ist besonders wichtig, falls Klassen irgendwann umbenannt oder verschoben werden.
Trage folgendes in AppServiceProvider::boot() ein:
<?php // app/Providers/AppServiceProvider.php (Ausschnitt) use Illuminate\Database\Eloquent\Relations\Relation; public function boot(): void { Relation::enforceMorphMap([ 'article' => \App\Models\Article::class, 'project' => \App\Models\Project::class, // weitere trackbare Modelle hier ergänzen ]); }
Mit enforceMorphMap wirft Laravel eine Exception, falls ein Modell die Morph-Map nicht kennt – das verhindert stille Fehler.
Schritt 4 – Smoke-Test mit Tinker
Bevor wir die Action in Livewire einbinden (das folgt in Teil 3), testen wir sie direkt über die Artisan-Konsole.
php artisan tinker
// Im Tinker-REPL: $article = App\Models\Article::first(); $action = new App\Actions\RecordVisitAction(); // Ersten Aufruf simulieren $action->execute($article); // Zeige den aktuellen Stand App\Models\Visit::where('visitable_id', $article->id) ->where('visitable_type', 'article') ->get(['visitable_type', 'visitable_id', 'visited_on', 'count']); // Zweiten Aufruf simulieren – count sollte jetzt 2 sein $action->execute($article); App\Models\Visit::where('visitable_id', $article->id) ->where('visitable_type', 'article') ->get(['visitable_type', 'visitable_id', 'visited_on', 'count']);
Erwartetes Ergebnis:
// Nach dem ersten execute():
=> Illuminate\Database\Eloquent\Collection {
0 => App\Models\Visit { visitable_type: "article", visitable_id: 1, visited_on: "2025-07-15", count: 1 }
}
// Nach dem zweiten execute():
=> Illuminate\Database\Eloquent\Collection {
0 => App\Models\Visit { visitable_type: "article", visitable_id: 1, visited_on: "2025-07-15", count: 2 }
}
Nur ein Datensatz für heute – der Zähler wird hochgezählt, keine neue Zeile entsteht.
Ergebnis prüfen
Nach diesem Teil sollte folgendes zutreffen:
-
app/Actions/RecordVisitAction.phpexistiert und enthält dieupsert-Logik. -
app/Concerns/HasVisits.phpexistiert mit den Hilfsmethodenvisits(),totalVisits(),todayVisits(). -
ArticleundProjectverwenden denHasVisits-Trait. - Die Morph-Map ist in
AppServiceProviderregistriert. - Der Tinker-Test erzeugt genau einen Visit-Datensatz pro Tag und inkrementiert
countbei Wiederholung. - An keiner Stelle werden IP-Adresse, User-Agent oder Session-ID referenziert.
Stolpersteine
upsert aktualisiert updated_at nicht automatisch
Laravels upsert() schreibt updated_at nur dann, wenn du es explizit in das update-Array aufnimmst. Da wir updated_at in der Tabelle bewusst weglassen (siehe Teil 1), ist das kein Problem – aber falls du es nachträglich hinzufügst, musst du es manuell mitführen: 'updated_at' => now().
Treiberspezifisches Verhalten von upsert
DB::raw('count + 1') funktioniert mit MySQL/MariaDB und PostgreSQL. SQLite ≥ 3.24 unterstützt ON CONFLICT DO UPDATE ebenfalls, jedoch mit leicht anderem Syntax – Laravel abstrahiert das korrekt, solange du keine rohen SQL-Strings außerhalb von DB::raw() verwendest.
enforceMorphMap schlägt fehl, wenn ein Modell fehlt
Wenn du ein neues trackbares Modell anlegst (z. B. Page), musst du es zuerst in der Morph-Map eintragen, bevor du die Action darauf aufrufst. Andernfalls wirft Laravel eine \RuntimeException.
Keine Daten in visits, obwohl upsert aufgerufen wurde
Prüfe, ob der Unique-Index aus Teil 1 korrekt angelegt ist:
SHOW INDEX FROM visits WHERE Key_name = 'visits_visitable_unique';
Fehlt der Index, legt upsert bei jedem Aufruf eine neue Zeile an, statt den bestehenden Datensatz zu aktualisieren.
In Teil 3 binden wir RecordVisitAction in die Livewire-Komponenten des öffentlichen Frontends ein – mit dem richtigen Lifecycle-Hook, damit das Tracking zuverlässig feuert, ohne den initialen Seitenaufbau zu verlangsamen.