Laravel-13-Tutorial #6: Eloquent-Modelle und Filament-Backend
geprüft am 7 September 2026 · 10 Min.
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.
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:
// 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:
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_tableDie Migration der Kategorien
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
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();
});
}Ä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
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
php artisan migrateINFO 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 DONEDie 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
<?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';
}
}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:
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:
<?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
<?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:
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:
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:
php artisan make:filament-resource Category --generate
php artisan make:filament-resource Tag --generate
php artisan make:filament-resource Post --generateDie 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:
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.phpLade /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:
<?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:
<?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'));
}
}
}php artisan migrate:fresh --seedfirstOrCreate 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.