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 mitmorphTo-Beziehung - Eine
VisitFactoryund einenVisitSeederfü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
php artisan make:migration create_visits_table
Laravel legt die neue Datei unter database/migrations/ an. Öffne sie und ersetze den Inhalt vollständig:
// 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 Spaltentrackable_type(z. B.App\Models\Article) undtrackable_id(z. B.42) sowie einen kombinierten Index darauf. Das macht die Beziehung polymorph – einVisit-Datensatz kann sich auf jedes beliebige Eloquent-Model beziehen.date(TypDATE) speichert nur den Kalendertag, keinen Timestamp. Das ist bewusst: Uhrzeiten würden die Anonymität senken und das Rollup verkomplizieren.countstartet bei1(der erste Aufruf des Tages erzeugt den Datensatz bereits mit Zähler 1).- Der
unique-Index übertrackable_type + trackable_id + dateist die entscheidende Datenbankgarantie: Er macht es unmöglich, für dieselbe (Seite, Tag)-Kombination zwei Zeilen anzulegen, und ermöglicht in Teil 2 den effizientenupsert-Befehl.
Schritt 2 – Eloquent-Model anlegen
php artisan make:model Visit
Öffne app/Models/Visit.php und befülle es:
// 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:
$fillableschü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 einCarbon-Objekt liefert, was spätere Vergleiche und Formatierungen erleichtert. morphTo()ohne Argumente funktioniert hier, weil Laravel den Konventionen folgt: es sucht nachtrackable_typeundtrackable_id– genau das, wasmorphs('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:
// 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
php artisan make:factory VisitFactory --model=Visit
// 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 viaArticle::factory()– praktisch für isolierte Tests. format('Y-m-d')liefert einen reinen Datums-String, den die Spalte vom TypDATEerwartet.
Schritt 4 – Seeder schreiben
php artisan make:seeder VisitSeeder
// 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:
// 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
php artisan migrate:fresh --seed
Erwartete Ausgabe (gekürzt):
Dropping all tables .............. DONE Running migrations ... ... create_visits_table ........ DONE Seeding database ... Database\Seeders\VisitSeeder ... DONE
Tabellenstruktur prüfen
php artisan tinker
// 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
$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.