Kapitel 6 von 9

Laravel-13-Tutorial #6: Eloquent-Modelle und Filament-Backend

geprüft am 7 September 2026 · 10 Min.

Schnelle Antwort

Lege die Tabellen mit php artisan make:model Post -m an, deklariere die Beziehungen in den Modellen und erzeuge anschließend die Backend-Oberflächen mit php artisan make:filament-resource Post --generate, das die Struktur der Tabellen liest und das Formular für dich schreibt. In Laravel 13 lässt sich die Konfiguration der Modelle mit den PHP-Attributen #[Fillable] und #[Scope] schreiben.

Am Ende dieses Kapitels, hast du vier Tabellen, ihre Eloquent-Modelle samt Beziehungen und ein Backend, mit dem sich Artikel anlegen lassen.

Dieses Kapitel legt die Tabellen des Blogs an, die Eloquent-Modelle dazu, die Beziehungen zwischen ihnen und die Backend-Oberflächen, mit denen du endlich Artikel schreiben kannst.

Der Aufbau der Datenbank

Ein einfacher Blog braucht vier Tabellen. Eine fünfte, die Pivot-Tabelle, verbindet Artikel und Tags.

Tabelle Zweck
users Die Autoren. Bringt Laravel mit.
categories Eine Kategorie pro Artikel.
tags Die Tags, mehrere pro Artikel.
posts Die Artikel.
post_tag Die Pivot-Tabelle zwischen Artikeln und Tags.

Die Beziehungen

  • Ein Benutzer hat mehrere Artikel, ein Artikel gehört zu einem Benutzer.
  • Eine Kategorie hat mehrere Artikel, ein Artikel gehört zu einer Kategorie.
  • Ein Artikel hat mehrere Tags, und ein Tag gehört zu mehreren Artikeln: eine n:m-Beziehung, daher die Pivot-Tabelle.

Die vierte Form, die hier nicht gebraucht wird

Eloquent kennt auch die 1:1-Beziehung: Ein Benutzer hat genau ein Profil. Sie steht auf der einen Seite mit hasOne und auf der anderen mit belongsTo:

php
// app/Models/User.php
public function profil(): HasOne
{
    return $this->hasOne(Profil::class);
}

// app/Models/Profil.php
public function user(): BelongsTo
{
    return $this->belongsTo(User::class);
}

Dieser Blog braucht sie nicht, aber sobald eine Anwendung wächst, taucht sie regelmäßig auf. Die komplexeren polymorphen Beziehungen beschreibt die Eloquent-Dokumentation.

Der Name der Pivot-Tabelle ist nicht frei wählbar. Eloquent leitet ihn ab: beide Modellnamen im Singular, klein geschrieben, alphabetisch sortiert, mit einem Unterstrich verbunden. Aus post und tag wird post_tag. Nennst du sie tag_post, findet die Beziehung nichts – ohne eine verständliche Fehlermeldung.

Modelle und Migrationen anlegen

Die Option -m erzeugt Modell und Migration in einem Zug:

bash
php artisan make:model Category -m
php artisan make:model Tag -m
php artisan make:model Post -m
php artisan make:migration create_post_tag_table

Die Migration der Kategorien

database/migrations/xxxx_create_categories_table.php
public function up(): void
{
    Schema::create('categories', function (Blueprint $table) {
        $table->id();
        $table->string('name');
        $table->string('slug')->unique();
        $table->text('description')->nullable();
        $table->timestamps();
    });
}

id() legt einen automatisch hochzählenden Primärschlüssel an. unique() stellt auf Datenbankebene sicher, dass keine zwei Kategorien denselben Slug tragen – ein festerer Schutz als eine Prüfung in PHP. nullable() lässt einen leeren Wert zu. timestamps() ergänzt created_at und updated_at, die Eloquent von allein pflegt.

Die Migration der Tags ist identisch, nur mit tags statt categories.

Die Migration der Artikel

database/migrations/xxxx_create_posts_table.php
public function up(): void
{
    Schema::create('posts', function (Blueprint $table) {
        $table->id();
        $table->foreignId('category_id')->constrained()->cascadeOnDelete();
        $table->foreignId('user_id')->constrained()->cascadeOnDelete();
        $table->string('title');
        $table->string('slug')->unique();
        $table->text('excerpt')->nullable();
        $table->longText('content');
        $table->string('featured_image')->nullable();
        $table->boolean('is_featured')->default(false);
        $table->boolean('is_published')->default(false);
        $table->timestamp('published_at')->nullable();
        $table->timestamps();
    });
}
foreignId statt bigInteger

Ältere Fassungen dieses Tutorials schrieben $table->bigInteger('category_id'). Die Spalte war da, aber nichts garantierte, dass sie auf eine echte Kategorie zeigt: Löschst du die Kategorie, behalten die Artikel eine verwaiste ID.

foreignId('category_id')->constrained() deklariert einen echten Fremdschlüssel. constrained() leitet die Tabelle aus dem Spaltennamen ab, und cascadeOnDelete() löscht die Artikel mit, sobald ihre Kategorie verschwindet. Ist das zu radikal, leert nullOnDelete() stattdessen die Spalte.

Die Migration der Pivot-Tabelle

database/migrations/xxxx_create_post_tag_table.php
public function up(): void
{
    Schema::create('post_tag', function (Blueprint $table) {
        $table->foreignId('post_id')->constrained()->cascadeOnDelete();
        $table->foreignId('tag_id')->constrained()->cascadeOnDelete();
        $table->primary(['post_id', 'tag_id']);
    });
}

Der zusammengesetzte Primärschlüssel verhindert, dass derselbe Tag zweimal am selben Artikel hängt. Die Pivot-Tabelle hat weder id() noch timestamps(): Sie hält nur eine Zuordnung fest.

Die Migrationen ausführen

bash
php artisan migrate
code
INFO  Running migrations.

  2026_09_02_195216_create_categories_table ................... 84.07ms DONE
  2026_09_02_195219_create_tags_table ......................... 43.53ms DONE
  2026_09_02_195221_create_posts_table ....................... 154.88ms DONE
  2026_09_02_195225_create_post_tag_table ..................... 19.50ms DONE

Die Reihenfolge zählt: posts verweist auf categories, seine Migration muss also später laufen. Laravel arbeitet sie in der alphabetischen Reihenfolge der Dateinamen ab, die mit einem Zeitstempel beginnen – es reicht, sie in der richtigen Reihenfolge anzulegen.

Um beim Lernen wieder bei null anzufangen, löscht php artisan migrate:fresh alle Tabellen und spielt alles neu ein. Setze diesen Befehl niemals auf einer Produktionsdatenbank ab.

Die Modelle

Category

app/Models/Category.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

#[Fillable(['name', 'slug', 'description'])]
class Category extends Model
{
    public function posts(): HasMany
    {
        return $this->hasMany(Post::class);
    }

    public function getRouteKeyName(): string
    {
        return 'slug';
    }
}
#[Fillable] gibt es nur in Laravel 13

Laravel 13 führt PHP-Attribute zur Konfiguration der Modelle ein: #[Fillable], #[Hidden], #[Table], #[ObservedBy] und rund zwanzig weitere. #[Scope] dagegen existiert seit Laravel 12.5. Das mit dem Framework gelieferte Modell User nutzt sie bereits.

Die klassische Form bleibt gültig und verhält sich identisch. Unter Laravel 12 oder früher schreiben Sie:

php
protected $fillable = ['name', 'slug', 'description'];

Diese Liste schützt vor Mass Assignment: Ohne sie könnte ein bösartiges Formular in jede beliebige Spalte schreiben, auch in is_published.

getRouteKeyName() sagt dem Route Model Binding aus Kapitel 2, dass es über die Spalte slug statt über id suchen soll. Genau das macht /categorie/laravel statt /categorie/1 möglich.

Tag

Identisch mit Category, nur die Beziehung ist eine n:m-Beziehung:

app/Models/Tag.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;

#[Fillable(['name', 'slug', 'description'])]
class Tag extends Model
{
    public function posts(): BelongsToMany
    {
        return $this->belongsToMany(Post::class);
    }

    public function getRouteKeyName(): string
    {
        return 'slug';
    }
}

Post

app/Models/Post.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;

#[Fillable([
    'category_id', 'user_id', 'title', 'slug', 'excerpt',
    'content', 'featured_image', 'is_featured', 'is_published', 'published_at',
])]
class Post extends Model
{
    protected function casts(): array
    {
        return [
            'is_featured' => 'boolean',
            'is_published' => 'boolean',
            'published_at' => 'datetime',
        ];
    }

    public function category(): BelongsTo
    {
        return $this->belongsTo(Category::class);
    }

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    public function tags(): BelongsToMany
    {
        return $this->belongsToMany(Tag::class);
    }

    #[Scope]
    protected function published(Builder $query): void
    {
        $query->where('is_published', true);
    }

    #[Scope]
    protected function featured(Builder $query): void
    {
        $query->where('is_featured', true);
    }

    public function getRouteKeyName(): string
    {
        return 'slug';
    }
}

casts() wandelt die Werte unterwegs um: SQLite speichert Booleans als 0 und 1, der Cast gibt sie an PHP als true und false zurück. published_at wird zu einem Datumsobjekt, sodass sich in den Views $post->published_at->translatedFormat('j F Y') schreiben lässt.

Scopes sind wiederverwendbare Filter. Statt in jedem Controller where('is_published', true) zu wiederholen, schreibst du:

php
Post::published()->get();
Post::published()->featured()->get();

Das Attribut #[Scope] existiert seit Laravel 12.5. In einer älteren Version muss die Methode scopePublished heißen und öffentlich sein: Laravel entfernt dann beim Aufruf das Präfix scope. Beide Formen erzeugen exakt dieselbe Abfrage.

User

Ergänze im vorhandenen Modell die Gegenrichtung der Beziehung:

app/Models/User.php
use Illuminate\Database\Eloquent\Relations\HasMany;

public function posts(): HasMany
{
    return $this->hasMany(Post::class);
}

Das Backend

Hier spielt Filament seinen Vorsprung gegenüber Voyager aus. Ein Befehl liest die Struktur deiner Tabellen und schreibt die passenden Oberflächen:

bash
php artisan make:filament-resource Category --generate
php artisan make:filament-resource Tag --generate
php artisan make:filament-resource Post --generate

Die Option --generate erledigt die ganze Arbeit: Sie sieht sich die Spalten an und leitet daraus den Feldtyp ab. Aus einem boolean wird ein Schalter, aus einem timestamp ein Datumsfeld, aus der Spalte category_id ein Auswahlfeld, das auf die Tabelle categories zeigt.

Pro Ressource entstehen sechs Dateien in app/Filament/Resources:

code
app/Filament/Resources/Posts/PostResource.php
app/Filament/Resources/Posts/Pages/CreatePost.php
app/Filament/Resources/Posts/Pages/EditPost.php
app/Filament/Resources/Posts/Pages/ListPosts.php
app/Filament/Resources/Posts/Schemas/PostForm.php
app/Filament/Resources/Posts/Tables/PostsTable.php

Lade /admin neu: Im Menü stehen jetzt die Einträge „Categories“, „Tags“ und „Posts“, mit voll funktionsfähigen Listen-, Anlege- und Bearbeitungsansichten. Nichts zusammenzuklicken, keine Konfigurationstabelle in der Datenbank.

Das Artikelformular verfeinern

Das erzeugte Formular funktioniert, ist aber roh: Der Slug muss von Hand eingetippt werden, und die Tags fehlen ganz. Öffne PostForm.php und ersetze den Inhalt:

app/Filament/Resources/Posts/Schemas/PostForm.php
<?php

namespace App\Filament\Resources\Posts\Schemas;

use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\RichEditor;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Schema;
use Illuminate\Support\Str;

class PostForm
{
    public static function configure(Schema $schema): Schema
    {
        return $schema
            ->components([
                TextInput::make('title')
                    ->label('Titre')
                    ->required()
                    ->live(onBlur: true)
                    ->afterStateUpdated(fn (?string $state, callable $set) => $set('slug', Str::slug((string) $state))),
                TextInput::make('slug')
                    ->required()
                    ->unique(ignoreRecord: true),
                Select::make('category_id')
                    ->label('Catégorie')
                    ->relationship('category', 'name')
                    ->required(),
                Select::make('user_id')
                    ->label('Auteur')
                    ->relationship('user', 'name')
                    ->default(fn () => auth()->id())
                    ->required(),
                Select::make('tags')
                    ->label('Étiquettes')
                    ->relationship('tags', 'name')
                    ->multiple()
                    ->preload(),
                Textarea::make('excerpt')
                    ->label('Résumé')
                    ->rows(3)
                    ->columnSpanFull(),
                RichEditor::make('content')
                    ->label('Contenu')
                    ->required()
                    ->columnSpanFull(),
                FileUpload::make('featured_image')
                    ->label('Image à la une')
                    ->image()
                    ->directory('posts'),
                Toggle::make('is_featured')->label('Mis en avant'),
                Toggle::make('is_published')->label('Publié'),
                DateTimePicker::make('published_at')->label('Date de publication')->default(now()),
            ]);
    }
}

Vier Ergänzungen verändern den Alltag. live(onBlur: true) gefolgt von afterStateUpdated füllt den Slug automatisch aus, sobald Sie das Titelfeld verlassen. Select::make('tags')->relationship('tags', 'name')->multiple() kümmert sich ganz allein um die Pivot-Tabelle, ohne eine zusätzliche Codezeile. RichEditor ersetzt das rohe Textfeld durch einen formatierten Editor. Schließlich füllt default(now()) das Veröffentlichungsdatum vor: Ohne diese Option landet ein ohne Datum gespeicherter Artikel auf der letzten Seite der Startseite, die nach published_at sortiert.

Mach dasselbe für CategoryForm und TagForm, mit dem automatischen Slug am Feld name.

Daten zum Arbeiten

Statt zwölf Artikel von Hand einzutippen, um die Pagination zu testen, schreibst du einen Seeder:

database/seeders/DatabaseSeeder.php
<?php

namespace Database\Seeders;

use App\Models\Category;
use App\Models\Post;
use App\Models\Tag;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Str;

class DatabaseSeeder extends Seeder
{
    public function run(): void
    {
        $author = User::firstOrCreate(
            ['email' => 'admin@gekkode.test'],
            ['name' => 'Damien', 'password' => 'motdepasse123']
        );

        $categories = collect(['Laravel', 'PHP', 'Front-end'])
            ->map(fn (string $name) => Category::firstOrCreate(
                ['slug' => Str::slug($name)],
                ['name' => $name, 'description' => "Articles sur {$name}."]
            ));

        $tags = collect(['Eloquent', 'Blade', 'Filament', 'Vite'])
            ->map(fn (string $name) => Tag::firstOrCreate(
                ['slug' => Str::slug($name)],
                ['name' => $name]
            ));

        foreach (range(1, 12) as $i) {
            $post = Post::firstOrCreate(
                ['slug' => "article-de-demonstration-{$i}"],
                [
                    'category_id' => $categories->random()->id,
                    'user_id' => $author->id,
                    'title' => "Article de démonstration {$i}",
                    'excerpt' => "Résumé court de l'article {$i}.",
                    'content' => "<p>Contenu de l'article {$i}.</p>",
                    'is_published' => true,
                    'is_featured' => $i <= 3,
                    'published_at' => now()->subDays($i),
                ]
            );

            $post->tags()->sync($tags->random(2)->pluck('id'));
        }
    }
}
bash
php artisan migrate:fresh --seed

firstOrCreate macht den Seeder wiederholbar, ohne Dubletten anzulegen. sync() ersetzt die Tags eines Artikels durch die übergebene Liste und schreibt dabei in die Pivot-Tabelle.

Das Passwort wird im Klartext übergeben: Der Cast 'password' => 'hashed', im Modell User standardmäßig vorhanden, hasht es beim Speichern.

Das nächste Kapitel verbindet diese Daten mit den Seiten der öffentlichen Website.

Häufige Fehler

Die n:m-Beziehung liefert nichts zurück Eloquent leitet den Namen der Pivot-Tabelle aus den Modellnamen ab: Singular, klein geschrieben, alphabetisch sortiert, also post_tag und nicht tag_post. Ein falscher Name scheitert lautlos.
bigInteger statt foreignId Eine reine Integer-Spalte garantiert nichts: Löschst du die Kategorie, behalten die Artikel eine verwaiste ID. foreignId('category_id')->constrained() deklariert einen echten Fremdschlüssel.
#[Fillable] wirft einen Fehler Dieses Attribut gibt es nur in Laravel 13. Unter Laravel 12 und älter schreibst du die klassische Eigenschaft protected $fillable = [...], die sich genauso verhält.
#[Scope] wirft einen Fehler Dasselbe gilt hier: Unter Laravel 12 muss die Methode scopePublished heißen und public sein.
migrate:fresh löscht alles Der Befehl entfernt alle Tabellen, bevor er die Migrationen neu einspielt. Niemals auf einer Produktionsdatenbank.
Newsletter

Neue Tests, Tutorials und Projekte, per E-Mail.

Reproduzierbare Tests, versionierter Code, datierte Ergebnisse. Niemals Spam.