Teil 1 – Datenmodell & Migration: Die Visit-Tabelle anlegen

Einordnung

Dies ist der Auftakt der vierteiligen Serie „Anonymes Visit-Tracking in Laravel & Filament: Tages-Rollup ohne Nutzeridentifikation". Wir bauen das Tracking-System, das auf Pixelklicker.de bereits im Einsatz ist (siehe Roadmap, Phase 4 – „Anonymes Aufruf-Tracking (Tagesrollup)"), von Grund auf nach – sauber dokumentiert und nachvollziehbar.

In diesem ersten Teil legen wir das Fundament: das Datenbankschema und das Eloquent-Model. Die eigentliche Tracking-Logik (Upsert-Mechanismus) folgt in Teil 2, die Livewire-Integration in Teil 3 und das Filament-Dashboard-Widget in Teil 4.

Was du nach diesem Teil hast:

  • Eine visits-Tabelle mit polymorpher Beziehung, Datumsfeld und Zähler
  • Einen eindeutigen Index, der das Tages-Rollup-Prinzip erzwingt
  • Ein Visit-Eloquent-Model mit morphTo-Beziehung
  • Eine VisitFactory und einen VisitSeeder für Demodaten
  • Ein erfolgreich durchlaufendes migrate:fresh --seed

Hintergrund: Warum dieses Datenmodell?

Anonymes Tracking bedeutet: keine Nutzer-ID, keine IP-Adresse, kein Fingerprint – nur die Antwort auf die Frage „Wie oft wurde Seite X am Tag Y aufgerufen?". Das Tages-Rollup-Prinzip speichert diese Information als einen einzigen Datensatz pro (Seite, Tag)-Kombination und inkrementiert bei jedem weiteren Aufruf nur den Zähler count. Das ist DSGVO-freundlich, platzsparend und direkt aggregierbar.

Die polymorphe Beziehung (trackable_type / trackable_id) erlaubt es uns, sowohl Article- als auch Project-Aufrufe – und potenziell weitere Modelle – in einer einzigen Tabelle zu erfassen, ohne für jeden Typ eine eigene Tabelle anlegen zu müssen.


Schritt 1 – Migration erstellen

bash
php artisan make:migration create_visits_table

Laravel legt die neue Datei unter database/migrations/ an. Öffne sie und ersetze den Inhalt vollständig:

php
// database/migrations/xxxx_xx_xx_xxxxxx_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();

            // Polymorphe Beziehung: welches Modell wurde aufgerufen?
            $table->morphs('trackable'); // erzeugt trackable_type (string) + trackable_id (unsignedBigInt)

            // Datum des Tages (kein Timestamp – nur der Kalendertag)
            $table->date('date');

            // Aufruf-Zähler für diesen Tag
            $table->unsignedInteger('count')->default(1);

            $table->timestamps();

            // Eindeutiger Index: pro (Modell-Typ, Modell-ID, Tag) darf es nur einen Datensatz geben.
            // Dieser Index ist die technische Grundlage des Tages-Rollups (upsert in Teil 2).
            $table->unique(['trackable_type', 'trackable_id', 'date'], 'visits_trackable_date_unique');
        });
    }

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

Was & Warum:

  • morphs('trackable') ist Laravel-Kurzschreibweise für die beiden Spalten trackable_type (z. B. App\Models\Article) und trackable_id (z. B. 42) sowie einen kombinierten Index darauf. Das macht die Beziehung polymorph – ein Visit-Datensatz kann sich auf jedes beliebige Eloquent-Model beziehen.
  • date (Typ DATE) speichert nur den Kalendertag, keinen Timestamp. Das ist bewusst: Uhrzeiten würden die Anonymität senken und das Rollup verkomplizieren.
  • count startet bei 1 (der erste Aufruf des Tages erzeugt den Datensatz bereits mit Zähler 1).
  • Der unique-Index über trackable_type + trackable_id + date ist die entscheidende Datenbankgarantie: Er macht es unmöglich, für dieselbe (Seite, Tag)-Kombination zwei Zeilen anzulegen, und ermöglicht in Teil 2 den effizienten upsert-Befehl.

Schritt 2 – Eloquent-Model anlegen

bash
php artisan make:model Visit

Öffne app/Models/Visit.php und befülle es:

php
// app/Models/Visit.php

namespace App\Models;

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

class Visit extends Model
{
    use HasFactory;

    /**
     * Massenzuweisbare Felder.
     * 'count' wird beim Upsert (Teil 2) direkt gesetzt/hochgezählt.
     */
    protected $fillable = [
        'trackable_type',
        'trackable_id',
        'date',
        'count',
    ];

    /**
     * Typ-Casting: 'date' wird als Carbon-Date-Instanz geliefert,
     * 'count' als Integer.
     */
    protected $casts = [
        'date'  => 'date',
        'count' => 'integer',
    ];

    /**
     * Polymorphe Beziehung: gibt das zugehörige Modell zurück
     * (z. B. Article oder Project).
     */
    public function trackable(): MorphTo
    {
        return $this->morphTo();
    }
}

Was & Warum:

  • $fillable schützt vor Mass-Assignment-Fehlern und listet alle Felder, die wir beim Upsert in Teil 2 schreiben werden.
  • Das 'date'-Cast sorgt dafür, dass Laravel beim Lesen automatisch ein Carbon-Objekt liefert, was spätere Vergleiche und Formatierungen erleichtert.
  • morphTo() ohne Argumente funktioniert hier, weil Laravel den Konventionen folgt: es sucht nach trackable_type und trackable_id – genau das, was morphs('trackable') in der Migration angelegt hat.

Die Gegenseite der Beziehung (optional, aber empfohlen)

Damit du von einem Article oder Project aus direkt auf dessen Visits zugreifen kannst, füge in den entsprechenden Modellen eine morphMany-Beziehung hinzu:

php
// app/Models/Article.php  (analog in Project.php)

use Illuminate\Database\Eloquent\Relations\MorphMany;

public function visits(): MorphMany
{
    return $this->morphMany(Visit::class, 'trackable');
}

Schritt 3 – Factory anlegen

bash
php artisan make:factory VisitFactory --model=Visit
php
// database/factories/VisitFactory.php

namespace Database\Factories;

use App\Models\Article;
use App\Models\Visit;
use Illuminate\Database\Eloquent\Factories\Factory;

class VisitFactory extends Factory
{
    protected $model = Visit::class;

    public function definition(): array
    {
        // Als Standard-Trackable nehmen wir Article; im Seeder variieren wir das.
        return [
            'trackable_type' => Article::class,
            'trackable_id'   => Article::factory(),
            'date'           => fake()->dateTimeBetween('-30 days', 'now')->format('Y-m-d'),
            'count'          => fake()->numberBetween(1, 250),
        ];
    }
}

Was & Warum:

  • Die Factory erzeugt bei Bedarf auch gleich einen neuen Article-Datensatz via Article::factory() – praktisch für isolierte Tests.
  • format('Y-m-d') liefert einen reinen Datums-String, den die Spalte vom Typ DATE erwartet.

Schritt 4 – Seeder schreiben

bash
php artisan make:seeder VisitSeeder
php
// database/seeders/VisitSeeder.php

namespace Database\Seeders;

use App\Models\Article;
use App\Models\Project;
use App\Models\Visit;
use Illuminate\Database\Seeder;

class VisitSeeder extends Seeder
{
    public function run(): void
    {
        // Sicherstellen, dass trackbare Modelle vorhanden sind
        $articles = Article::all();
        $projects = Project::all();

        // Für jeden Artikel: Aufrufe für die letzten 14 Tage erzeugen
        foreach ($articles as $article) {
            foreach (range(0, 13) as $daysAgo) {
                Visit::factory()->create([
                    'trackable_type' => Article::class,
                    'trackable_id'   => $article->id,
                    'date'           => now()->subDays($daysAgo)->format('Y-m-d'),
                    'count'          => fake()->numberBetween(5, 150),
                ]);
            }
        }

        // Für jedes Projekt: Aufrufe für die letzten 14 Tage erzeugen
        foreach ($projects as $project) {
            foreach (range(0, 13) as $daysAgo) {
                Visit::factory()->create([
                    'trackable_type' => Project::class,
                    'trackable_id'   => $project->id,
                    'date'           => now()->subDays($daysAgo)->format('Y-m-d'),
                    'count'          => fake()->numberBetween(3, 80),
                ]);
            }
        }
    }
}

Registriere den Seeder im DatabaseSeeder:

php
// database/seeders/DatabaseSeeder.php

public function run(): void
{
    // … andere Seeder …
    $this->call(VisitSeeder::class);
}

Wichtig: Der VisitSeeder setzt voraus, dass Article- und Project-Datensätze bereits existieren. Stelle sicher, dass deren Seeder vor dem VisitSeeder aufgerufen werden.


Schritt 5 – Migration ausführen und prüfen

bash
php artisan migrate:fresh --seed

Erwartete Ausgabe (gekürzt):

Code
Dropping all tables .............. DONE
Running migrations ...
  ... create_visits_table ........ DONE
Seeding database ...
  Database\Seeders\VisitSeeder ... DONE

Tabellenstruktur prüfen

bash
php artisan tinker
php
// Im Tinker-REPL:
Schema::getColumnListing('visits');
// Erwartet: ['id', 'trackable_type', 'trackable_id', 'date', 'count', 'created_at', 'updated_at']

Visit::count();
// Erwartet: Anzahl Artikel × 14 + Anzahl Projekte × 14

Visit::first();
// Zeigt einen vollständigen Datensatz zur Sichtkontrolle

Polymorphe Beziehung testen

php
$visit = Visit::first();
$visit->trackable;       // liefert das zugehörige Article- oder Project-Objekt
$visit->trackable_type;  // z. B. "App\Models\Article"
$visit->trackable_id;    // z. B. 3

Ergebnis prüfen

Nach erfolgreichem Durchlauf solltest du Folgendes verifizieren können:

Prüfpunkt Erwartetes Ergebnis
visits-Tabelle vorhanden Schema::hasTable('visits')true
Eindeutiger Index gesetzt ✅ Doppelter Insert (gleiche type/id/date) wirft QueryException
Visit::first()->trackable ✅ Gibt ein Article- oder Project-Objekt zurück
Article::first()->visits ✅ Gibt eine Collection von Visit-Objekten zurück
Visit::count() > 0 ✅ Seeder hat Demodaten erzeugt

Stolpersteine

SQLSTATE[23000]: Integrity constraint violation beim Seeden Der eindeutige Index schlägt an, wenn der Seeder für dieselbe (trackable_type, trackable_id, date)-Kombination zweimal einen Datensatz anlegen will. Stelle sicher, dass du keine Artikel/Projekte mit doppelten IDs hast und dass der Seeder jede Kombination nur einmal erzeugt. Im Zweifel zuerst migrate:fresh ausführen.

Class "App\Models\Article" not found in der Factory Prüfe, ob das Namespace-Prefix in trackable_type korrekt ist. Laravel speichert standardmäßig den vollqualifizierten Klassennamen (z. B. App\Models\Article). Weicht deine Projektstruktur ab, muss das in der Factory angepasst werden.

morphTo() gibt null zurück Das passiert, wenn trackable_type einen Klassennamen enthält, der nicht (mehr) existiert – zum Beispiel nach einem Refactoring. Stelle sicher, dass alle in trackable_type eingetragenen Klassen tatsächlich vorhanden und korrekt autoloaded sind.

Seeder-Reihenfolge falsch Wenn VisitSeeder vor ArticleSeeder/ProjectSeeder läuft, ist Article::all() leer und es werden keine Visits erzeugt – ohne Fehlermeldung. Prüfe die Aufruf-Reihenfolge in DatabaseSeeder.


Ausblick

Das Fundament steht: Wir haben eine visits-Tabelle mit polymorpher Beziehung, Datumsfeld und Zähler, erzwungenem Tages-Unique-Index sowie einem befüllten Seeder. In Teil 2 implementieren wir die eigentliche Tracking-Logik – einen Service bzw. eine Action, die mit Laravels upsert()-Methode den Zähler für (trackable, date) atomar erhöht, ohne bei jedem Aufruf eine neue Zeile anzulegen.