Hoofdstuk 6 van 9

Laravel 13-tutorial #6: Eloquent-modellen en het adminpaneel

geverifieerd op 7 september 2026 · 10 min

Kort antwoord

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].

Aan het einde van dit hoofdstuk, heb je vier tabellen, hun Eloquent-modellen met de relaties, en een adminpaneel waarmee je artikelen kunt aanmaken.

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:

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);
}

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:

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

De migratie van de categorieën

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() 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

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 in plaats van bigInteger

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

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']);
    });
}

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

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

De 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

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] bestaat alleen in Laravel 13

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:

php
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:

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() 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:

php
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:

app/Models/User.php
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:

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

De 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:

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

Herlaad /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:

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 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:

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

Met 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.

Veelgemaakte fouten

De many-to-many-relatie geeft niets terug Eloquent leidt de naam van de pivottabel af door de modellen in het enkelvoud te zetten, in kleine letters, in alfabetische volgorde: post_tag en niet tag_post. Een verkeerde naam faalt stilletjes.
bigInteger in plaats van foreignId Een gewone integer-kolom garandeert niets: verwijder de categorie en de artikelen houden een verweesde id over. foreignId('category_id')->constrained() declareert een echte foreign key.
#[Fillable] geeft een fout Dat attribuut bestaat alleen in Laravel 13. Op Laravel 12 en ouder schrijf je de klassieke eigenschap protected $fillable = [...], die precies hetzelfde doet.
#[Scope] geeft een fout Hetzelfde verhaal: op Laravel 12 moet de methode scopePublished heten en public zijn.
migrate:fresh wist alles Het commando verwijdert alle tabellen voordat het de migraties opnieuw draait. Nooit op een productiedatabase.
Nieuwsbrief

Nieuwe tests, tutorials en projecten, per e-mail.

Reproduceerbare tests, geversioneerde code, gedateerde resultaten. Nooit spam.