Laravel 13-tutorial #6: Eloquent-modellen en het adminpaneel
geverifieerd op 7 september 2026 · 10 min
Maak de tabellen met php artisan make:model Post -m, declareer de relaties in de modellen en genereer daarna de schermen van het adminpaneel met php artisan make:filament-resource Post --generate, dat de structuur van de tabellen leest en het formulier voor je schrijft. In Laravel 13 kun je de configuratie van je modellen schrijven met de PHP-attributen #[Fillable] en #[Scope].
In dit hoofdstuk maak je de tabellen van de blog aan, de Eloquent-modellen die ze vertegenwoordigen, de relaties ertussen en de beheerschermen waarmee je eindelijk artikelen kunt schrijven.
De structuur van de database
Een eenvoudige blog heeft vier tabellen nodig. Een vijfde, de pivottabel, koppelt artikelen aan tags.
| Tabel | Rol |
|---|---|
users | De auteurs. Wordt door Laravel meegeleverd. |
categories | Eén categorie per artikel. |
tags | De tags, meerdere per artikel. |
posts | De artikelen. |
post_tag | De pivottabel tussen artikelen en tags. |
De relaties
- Een gebruiker heeft meerdere artikelen, een artikel hoort bij één gebruiker.
- Een categorie heeft meerdere artikelen, een artikel hoort bij één categorie.
- Een artikel heeft meerdere tags en een tag hoort bij meerdere artikelen: dat is een many-to-many-relatie, vandaar de pivottabel.
De vierde vorm, die je hier niet nodig hebt
Eloquent kent ook de one-to-one-relatie: een gebruiker heeft precies één profiel. Je schrijft die met hasOne aan de ene kant en belongsTo aan de andere:
// 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);
}Deze blog heeft er geen gebruik voor, maar zodra een applicatie groeit kom je de vorm regelmatig tegen. De ingewikkeldere polymorfe relaties staan beschreven in de documentatie van Eloquent.
De naam van de pivottabel ligt vast. Eloquent leidt hem af door beide modelnamen in het enkelvoud te zetten, in kleine letters, in alfabetische volgorde, gescheiden door een underscore: post en tag geven post_tag. Noem je hem tag_post, dan vindt de relatie niets, zonder duidelijke foutmelding.
De modellen en de migraties aanmaken
Met de optie -m maak je het model en zijn migratie in één beweging aan:
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_tableDe migratie van de categorieën
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() maakt een automatisch oplopende primaire sleutel. unique() garandeert op databaseniveau dat geen twee categorieën dezelfde slug krijgen, steviger dan een controle in PHP. nullable() staat een lege waarde toe. timestamps() voegt created_at en updated_at toe, die Eloquent zelf bijhoudt.
De migratie van de tags is identiek, met tags in plaats van categories.
De migratie van de artikelen
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();
});
}Oudere versies van deze tutorial schreven $table->bigInteger('category_id'). De kolom bestond, maar niets garandeerde dat ze naar een bestaande categorie wees: verwijder je de categorie, dan houden de artikelen een verweesde id over.
foreignId('category_id')->constrained() declareert een echte foreign key. constrained() leidt de tabel af uit de kolomnaam en cascadeOnDelete() verwijdert de artikelen zodra hun categorie verdwijnt. Is dat te rigoureus, dan maakt nullOnDelete() de kolom leeg in plaats daarvan.
De migratie van de pivottabel
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']);
});
}De samengestelde primaire sleutel voorkomt dat dezelfde tag twee keer aan hetzelfde artikel hangt. De pivottabel heeft geen id() en geen timestamps(): ze legt alleen een koppeling vast.
De migraties uitvoeren
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 DONEDe volgorde telt: posts verwijst naar categories, dus die migratie moet later draaien. Laravel verwerkt ze op alfabetische volgorde van de bestandsnamen, die met een timestamp beginnen, ze in de juiste volgorde aanmaken is genoeg.
Wil je tijdens het leren opnieuw beginnen, dan gooit php artisan migrate:fresh alle tabellen weg en draait alles opnieuw. Voer dat commando nooit uit op een productiedatabase.
De modellen
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 introduceert PHP-attributen om modellen te configureren: #[Fillable], #[Hidden], #[Table], #[ObservedBy] en een twintigtal andere. #[Scope] bestaat daarentegen sinds Laravel 12.5. Het model User dat met het framework wordt meegeleverd gebruikt ze al.
De klassieke vorm blijft geldig en werkt identiek. Op Laravel 12 of ouder schrijf je:
protected $fillable = ['name', 'slug', 'description'];Deze lijst beschermt tegen mass assignment: zonder deze lijst zou een kwaadaardig formulier naar elke kolom kunnen schrijven, ook naar is_published.
getRouteKeyName() vertelt de route model binding uit hoofdstuk 2 dat ze op de kolom slug moet zoeken in plaats van op id. Dat maakt /categorie/laravel mogelijk in plaats van /categorie/1.
Tag
Identiek aan Category, op de relatie na: die is many-to-many:
<?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() zet de waarden onderweg om: SQLite slaat booleans op als 0 en 1, de cast levert ze in PHP terug als true en false. published_at wordt een date-object, waardoor je in de views $post->published_at->translatedFormat('j F Y') kunt schrijven.
Scopes zijn herbruikbare filters. In plaats van in elke controller where('is_published', true) te herhalen, schrijf je:
Post::published()->get();
Post::published()->featured()->get();Het attribuut #[Scope] bestaat sinds Laravel 12.5. Op een oudere versie moet de methode scopePublished heten en publiek zijn: Laravel haalt dan bij de aanroep het voorvoegsel scope weg. Beide vormen leveren exact dezelfde query op.
User
Voeg de omgekeerde relatie toe aan het bestaande model:
use Illuminate\Database\Eloquent\Relations\HasMany;
public function posts(): HasMany
{
return $this->hasMany(Post::class);
}Het adminpaneel
Hier maakt Filament het verschil met Voyager. Eén commando leest de structuur van je tabellen en schrijft de bijbehorende schermen:
php artisan make:filament-resource Category --generate
php artisan make:filament-resource Tag --generate
php artisan make:filament-resource Post --generateDe optie --generate doet al het werk: ze inspecteert de kolommen en leidt per kolom het veldtype af. Een boolean wordt een schakelaar, een timestamp een datumkiezer, een kolom category_id een keuzelijst die aan de tabel categories hangt.
Per resource verschijnen er zes bestanden 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.phpHerlaad /admin: in het menu staan nu ‘Categories’, ‘Tags’ en ‘Posts’, met volwaardige schermen om te lijsten, aan te maken en te bewerken. Niets aan te klikken, geen configuratietabel in de database.
Het artikelformulier bijschaven
Het gegenereerde formulier werkt, maar blijft ruw: je moet de slug met de hand intikken en de tags ontbreken. Open PostForm.php en vervang de inhoud:
<?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 toevoegingen veranderen het dagelijks gebruik. live(onBlur: true) gevolgd door afterStateUpdated vult de slug automatisch in zodra je het titelveld verlaat. Select::make('tags')->relationship('tags', 'name')->multiple() regelt de pivot-tabel helemaal zelf, zonder één extra regel code. RichEditor vervangt het kale tekstvak door een opgemaakte editor. Tot slot vult default(now()) de publicatiedatum vooraf in: zonder die optie belandt een artikel dat zonder datum is opgeslagen op de laatste pagina van de homepage, die sorteert op published_at.
Doe hetzelfde voor CategoryForm en TagForm, met de automatische slug op het veld name.
Data om mee te werken
In plaats van twaalf artikelen met de hand in te voeren om de paginering te testen, schrijf je een 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 --seedMet firstOrCreate kun je de seeder opnieuw draaien zonder duplicaten aan te maken. sync() vervangt de tags van een artikel door de meegegeven lijst en schrijft daarvoor in de pivottabel.
Het wachtwoord gaat er in platte tekst in: de cast 'password' => 'hashed', die standaard op het model User staat, hasht het bij het opslaan.
Het volgende hoofdstuk koppelt deze data aan de pagina’s van de publieke site.