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.

bash
php artisan make:class App/Actions/RecordVisitAction

Öffne die neu erstellte Datei und ersetze ihren Inhalt vollständig:

php
<?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 ein INSERT. Existiert für die Kombination (visitable_type, visitable_id, visited_on) bereits ein Datensatz (dank des Unique-Index aus Teil 1), greift stattdessen das ON DUPLICATE KEY UPDATE / ON CONFLICT DO UPDATE des jeweiligen Datenbanktreibers.
  • DB::raw('count + 1') inkrementiert den Zähler atomar auf Datenbankebene – kein Race-Condition-Problem bei parallelen Requests.
  • now()->toDateString() liefert nur YYYY-MM-DD. Uhrzeiten werden bewusst verworfen.
  • Kein request()->ip(), kein request()->userAgent(), keine session()->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.

bash
php artisan make:class App/Concerns/HasVisits --invokable

Hinweis: --invokable wird hier nur verwendet, damit Artisan die Datei anlegt. Wir ersetzen den Inhalt vollständig – es wird kein Invokable-Interface benötigt.

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

// app/Models/Article.php  (Ausschnitt)

use App\Concerns\HasVisits;

class Article extends Model
{
    use HasVisits;
    // ... restliche Klasse
}
php
<?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
<?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.

bash
php artisan tinker
php
// 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:

Code
// 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.php existiert und enthält die upsert-Logik.
  • app/Concerns/HasVisits.php existiert mit den Hilfsmethoden visits(), totalVisits(), todayVisits().
  • Article und Project verwenden den HasVisits-Trait.
  • Die Morph-Map ist in AppServiceProvider registriert.
  • Der Tinker-Test erzeugt genau einen Visit-Datensatz pro Tag und inkrementiert count bei 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:

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