Teil 3 – Livewire-Integration: Tracking im Frontend auslösen

Einordnung

In Teil 1 haben wir die visits-Tabelle mit polymorpher Beziehung und Tages-Unique-Index angelegt. In Teil 2 haben wir die RecordVisitAction implementiert, die Aufrufe per upsert/increment als Tages-Rollup speichert – ohne personenbezogene Daten. Jetzt verbinden wir diese Logik mit dem öffentlichen Frontend: Konkret rufen wir die Action in den Livewire-Komponenten für die Artikel-Detailseite und die Projekt-Detailseite auf, sodass jeder Seitenaufruf zuverlässig und performant gezählt wird.


Vorüberlegung: Welcher Lifecycle-Hook ist der richtige?

Livewire bietet mehrere Hooks an, in denen man Code ausführen kann. Für das Tracking kommen vor allem zwei in Frage:

Hook Wird ausgeführt … Eignet sich für Tracking?
mount() Einmal beim ersten Rendern der Komponente auf dem Server ✅ Ja – genau einmal pro Seitenaufruf
booted() Bei jedem Server-Roundtrip (auch bei Updates, Events, …) ❌ Nein – würde bei jeder Interaktion erneut zählen

mount() ist die richtige Wahl. Er wird genau einmal ausgeführt, wenn der Nutzer die Seite aufruft – und nicht erneut, wenn Livewire später einen Update-Request schickt (z. B. für den Scrollspy der ToC-Sidebar).

Was ist mit wire:navigate?

Das Pixelklicker-Frontend nutzt wire:navigate für SPA-ähnliche Seitenübergänge. Dabei wird die neue Seite per AJAX geladen und die Livewire-Komponente neu gemountet – mount() wird also erneut aufgerufen. Das klingt zunächst nach einer Doppelzählungsgefahr, ist es aber strukturell nicht: Wenn ein Nutzer von Artikel A zu Artikel B navigiert, ist das tatsächlich ein neuer Aufruf von Artikel B, der gezählt werden soll.

Die eigentliche Absicherung gegen Doppelzählungen liegt in der Rollup-Logik aus Teil 2: Der Tages-Unique-Index auf (trackable_type, trackable_id, date) stellt sicher, dass mehrfaches Aufrufen derselben Seite am selben Tag nur einen Datensatz erzeugt und lediglich den Zähler erhöht – kein Datenmüll, keine aufgeblähten Zahlen.


Schritt 1: Artikel-Detailseite tracken

Die Livewire-Komponente für die Artikel-Detailseite befindet sich unter app/Livewire/ArticleShow.php. Wir injizieren die RecordVisitAction direkt in mount().

php
// app/Livewire/ArticleShow.php

<?php

namespace App\Livewire;

use App\Actions\RecordVisitAction;
use App\Models\Article;
use Livewire\Component;

class ArticleShow extends Component
{
    public Article $article;

    public function mount(Article $article, RecordVisitAction $recordVisit): void
    {
        $this->article = $article;

        $recordVisit->execute($article);
    }

    public function render()
    {
        return view('livewire.article-show')
            ->layout('layouts.app');
    }
}

Was passiert hier?

  • Laravel löst RecordVisitAction automatisch per Dependency Injection auf – kein new, kein app().
  • $recordVisit->execute($article) ruft die in Teil 2 implementierte Logik auf: upsert mit increment auf den Tages-Rollup-Datensatz für diesen Artikel.
  • Der Aufruf ist synchron, aber extrem schnell (ein einziges INSERT … ON DUPLICATE KEY UPDATE), sodass er die Render-Zeit nicht spürbar beeinflusst.

Schritt 2: Projekt-Detailseite tracken

Für die Projekt-Detailseite gehen wir identisch vor. Die Komponente liegt unter app/Livewire/ProjectShow.php.

php
// app/Livewire/ProjectShow.php

<?php

namespace App\Livewire;

use App\Actions\RecordVisitAction;
use App\Models\Project;
use Livewire\Component;

class ProjectShow extends Component
{
    public Project $project;

    public function mount(Project $recordVisit, RecordVisitAction $recordVisit): void
    {
        // Hinweis: Parameternamen müssen eindeutig sein – siehe Stolpersteine unten
    }

    // …
}

Korrigierte, vollständige Version:

php
// app/Livewire/ProjectShow.php

<?php

namespace App\Livewire;

use App\Actions\RecordVisitAction;
use App\Models\Project;
use Livewire\Component;

class ProjectShow extends Component
{
    public Project $project;

    public function mount(Project $project, RecordVisitAction $recordVisit): void
    {
        $this->project = $project;

        $recordVisit->execute($project);
    }

    public function render()
    {
        return view('livewire.project-show')
            ->layout('layouts.app');
    }
}

Warum funktioniert das polymorph? Die RecordVisitAction aus Teil 2 erwartet ein Model-Objekt und liest $trackable->getMorphClass() sowie $trackable->getKey() aus. Ob wir ihr ein Article- oder ein Project-Objekt übergeben, ist ihr gleichgültig – solange das Model die Morph-Map korrekt registriert hat (wie in Teil 1 eingerichtet).


Schritt 3: Morph-Map verifizieren

Damit die polymorphe Beziehung stabil und typsicher funktioniert, sollte die Morph-Map in AppServiceProvider oder einem dedizierten MorphMapServiceProvider registriert sein. Prüfe, dass Article und Project darin enthalten sind:

php
// app/Providers/AppServiceProvider.php

use Illuminate\Database\Eloquent\Relations\Relation;

public function boot(): void
{
    Relation::enforceMorphMap([
        'article' => \App\Models\Article::class,
        'project' => \App\Models\Project::class,
        // … weitere Modelle
    ]);
}

Warum enforceMorphMap? Die enforce-Variante wirft eine Exception, wenn ein Model ohne Eintrag in der Map gespeichert werden soll. Das schützt vor versehentlichem Speichern von voll qualifizierten Klassennamen (App\Models\Article) in der Datenbank – was Refactorings später erschwert.


Schritt 4: Ergebnis prüfen

4a – Browser-Test

  1. Starte den lokalen Entwicklungsserver (php artisan serve) und öffne eine Artikel-Detailseite im Browser.
  2. Lade die Seite einmal neu.
  3. Navigiere über wire:navigate zu einer anderen Artikelseite.
  4. Rufe eine Projekt-Detailseite auf.

4b – Datenbankprüfung per Tinker

bash
php artisan tinker
php
// In Tinker:

use App\Models\Visit;

// Alle heutigen Einträge anzeigen
Visit::whereDate('date', today())->get(['trackable_type', 'trackable_id', 'count']);

Erwartete Ausgabe (Beispiel):

Code
Illuminate\Database\Eloquent\Collection {
  all: [
    App\Models\Visit {
      trackable_type: "article",
      trackable_id: 3,
      count: 2,         // zweimal aufgerufen
    },
    App\Models\Visit {
      trackable_type: "article",
      trackable_id: 7,
      count: 1,
    },
    App\Models\Visit {
      trackable_type: "project",
      trackable_id: 1,
      count: 1,
    },
  ],
}

4c – Doppelzählung absichtlich testen

Rufe dieselbe Artikel-Detailseite fünfmal hintereinander auf. Der count-Wert für diesen Artikel sollte um genau 5 steigen – aber es darf nur ein Datensatz für heute in der Tabelle existieren (kein zweiter Zeile für denselben Artikel am selben Tag).

php
// In Tinker – nach fünf Aufrufen prüfen:

Visit::whereDate('date', today())
    ->where('trackable_type', 'article')
    ->where('trackable_id', 3)
    ->value('count');
// Erwartetes Ergebnis: 7 (2 vorherige + 5 neue)

Visit::whereDate('date', today())
    ->where('trackable_type', 'article')
    ->where('trackable_id', 3)
    ->count();
// Erwartetes Ergebnis: 1 (nur ein Datensatz!)

Stolpersteine

Parameternamen-Konflikt in mount()

Benenne die Parameter immer unterschiedlich. Ein häufiger Tippfehler:

php
// ❌ Falsch – beide Parameter heißen $project
public function mount(Project $project, RecordVisitAction $project): void
php
// ✅ Richtig
public function mount(Project $project, RecordVisitAction $recordVisit): void

Route-Model-Binding muss aktiv sein

Die Livewire-Komponente erwartet, dass das Model bereits aufgelöst wurde. Stelle sicher, dass die Route Route-Model-Binding nutzt:

php
// routes/web.php

Route::get('/devlog/{article:slug}', ArticleShow::class)->name('articles.show');
Route::get('/projekte/{project:slug}', ProjectShow::class)->name('projects.show');

Ohne das Binding würde Livewire versuchen, ein Model anhand der rohen URL-Parameter zu erzeugen – was in einem Fehler endet.

Tracking in Queue auslagern – wann lohnt es sich?

Für eine einfache upsert-Operation ist ein synchroner Aufruf in mount() absolut ausreichend. Falls du später zusätzliche Logik hinzufügst (z. B. Geolocation, User-Agent-Auswertung), kannst du die Action in einen Job auslagern:

php
// Optionale spätere Erweiterung – jetzt noch nicht nötig
dispatch(new RecordVisitJob($article));

Im aktuellen Stand des Projekts bleibt der synchrone Aufruf die sauberere und einfachere Lösung.

Tracking im Admin-Panel unterdrücken

Falls du nicht möchtest, dass Admin-Besuche im Tracking auftauchen, kannst du in der Action einen einfachen Guard einbauen:

php
// app/Actions/RecordVisitAction.php – optionale Ergänzung

public function execute(Model $trackable): void
{
    if (auth()->check() && auth()->user()->hasRole('admin')) {
        return;
    }

    // … bestehende Upsert-Logik
}

Zusammenfassung

Mit diesen Änderungen zählt das System jeden Aufruf einer Artikel- oder Projekt-Detailseite zuverlässig:

  • mount() wird genau einmal pro Seitenaufruf ausgeführt – auch bei wire:navigate.
  • Die polymorphe RecordVisitAction aus Teil 2 deckt beide Typen ohne Codeduplizierung ab.
  • Der Tages-Unique-Index aus Teil 1 schließt Doppelzeilen strukturell aus; Mehrfachaufrufe erhöhen nur den count.
  • Die Prüfung per Tinker bestätigt, dass Datensätze korrekt angelegt und inkrementiert werden.

Im letzten Teil der Serie visualisieren wir diese gesammelten Daten im Filament-Admin-Dashboard – als StatsOverview- oder Chart-Widget, das die aggregierten Tagesaufrufe übersichtlich darstellt.