Chapter 6 of 9

Laravel 13 tutorial #6: Eloquent models and admin panel

verified on 7 September 2026 · 10 min

Quick answer

Create the tables with php artisan make:model Post -m, declare the relations in the models, then generate the admin screens with php artisan make:filament-resource Post --generate, which reads the structure of the tables and writes the form for you. Laravel 13 lets you configure models with the PHP attributes #[Fillable] and #[Scope].

By the end of this chapter, you will have four tables, their Eloquent models with the relations, and an admin panel able to create posts.

This chapter creates the blog tables, the Eloquent models that represent them, the relations between them, and the admin screens that will finally let you write posts.

The database structure

A simple blog needs four tables. A fifth one, the pivot table, links posts to tags.

Table Role
users The authors. Shipped with Laravel.
categories One category per post.
tags The tags, several per post.
posts The posts.
post_tag The pivot table between posts and tags.

The relations

  • A user has many posts, a post belongs to a user.
  • A category has many posts, a post belongs to a category.
  • A post has many tags and a tag belongs to many posts: that is a “many to many” relation, hence the pivot table.

The fourth form, unused here

Eloquent also knows the “one to one” relation: a user owns a profile, and only one. It is written with hasOne on one side and belongsTo on the other:

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

This blog has no use for it, but the form turns up often once an application grows. Polymorphic relations, which are trickier, are covered in the Eloquent documentation.

The name of the pivot table is not yours to pick. Eloquent works it out by putting both model names in the singular, in lower case, in alphabetical order, separated by an underscore: post and tag give post_tag. Name it tag_post and the relation finds nothing, with no explicit error message.

Creating the models and the migrations

The -m option creates the model and its migration in one go:

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

The categories migration

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() creates an auto-incrementing primary key. unique() guarantees at database level that no category shares its slug with another, sturdier protection than a check written in PHP. nullable() allows an empty value. timestamps() adds created_at and updated_at, which Eloquent keeps up to date on its own.

The tags migration is identical, with tags in place of categories.

The posts migration

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 rather than bigInteger

Older versions of this tutorial wrote $table->bigInteger('category_id'). The column existed, but nothing guaranteed it pointed at a real category: delete the category and the posts kept an orphan identifier.

foreignId('category_id')->constrained() declares a real foreign key. constrained() works out the table from the column name, and cascadeOnDelete() deletes the posts when their category disappears. If that is too brutal, nullOnDelete() empties the column instead.

The pivot table migration

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

The composite primary key prevents the same tag from being attached twice to the same post. The pivot table has neither id() nor timestamps(): it carries nothing but an association.

Running the migrations

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

Order matters: posts references categories, so its migration has to run afterwards. Laravel processes them in alphabetical order of file name, and those names start with a timestamp, creating them in the right order is enough.

To start again from scratch while you are learning, php artisan migrate:fresh drops every table and replays the lot. Never run that command against a production database.

The models

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] is specific to Laravel 13

Laravel 13 introduces PHP attributes to configure models: #[Fillable], #[Hidden], #[Table], #[ObservedBy] and about twenty others. #[Scope], for its part, has existed since Laravel 12.5. The User model shipped with the framework already uses them.

The classic form remains valid and behaves identically. On Laravel 12 or earlier, write:

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

This list protects against mass assignment: without it, a malicious form could write to any column, including is_published.

getRouteKeyName() tells the route model binding from chapter 2 to look up the slug column rather than id. That is what makes /categorie/laravel possible instead of /categorie/1.

Tag

Identical to Category, apart from the relation, which 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() converts values on the way through: SQLite stores booleans as 0 and 1, and the cast hands them back to PHP as true and false. published_at becomes a date object, which is what lets you write $post->published_at->translatedFormat('j F Y') in the views.

Scopes are reusable filters. Instead of repeating where('is_published', true) in every controller, you write:

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

The #[Scope] attribute has existed since Laravel 12.5. On an earlier version, the method must be named scopePublished and be public: Laravel then strips the scope prefix when calling it. Both forms produce exactly the same query.

User

Add the inverse relation to the existing model:

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

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

The admin panel

This is where Filament changes everything compared with Voyager. One command reads the structure of your tables and writes the matching screens:

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

The --generate option does all the work: it inspects the columns and infers the field type of each one. A boolean becomes a toggle, a timestamp a date picker, and a category_id column a select bound to the categories table.

Six files per resource appear under 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

Reload /admin: the “Categories”, “Tags” and “Posts” entries have appeared in the menu, with fully working list, create and edit screens. Nothing to configure by clicking, no configuration table in the database.

Refining the post form

The generated form works but stays rough: it asks you to type the slug by hand and ignores tags. Open PostForm.php and replace its contents:

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

Four additions change day-to-day use. live(onBlur: true) followed by afterStateUpdated fills in the slug automatically when you leave the title field. Select::make('tags')->relationship('tags', 'name')->multiple() handles the pivot table on its own, without an extra line of code. RichEditor replaces the raw text area with a formatted editor. Finally default(now()) pre-fills the publication date: without it, a post saved without a date ends up on the last page of the home page, which sorts on published_at.

Do the same for CategoryForm and TagForm, with the automatic slug on the name field.

Data to work with

Rather than typing twelve posts by hand to test pagination, write a 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 makes the seeder replayable without creating duplicates. sync() replaces a post’s tags with the list you hand it, writing to the pivot table.

The password is passed in clear text: the 'password' => 'hashed' cast, present by default on the User model, hashes it on save.

The next chapter wires this data into the pages of the public site.

Common errors

The “many to many” relation returns nothing Eloquent works out the pivot table name by putting the models in the singular, in lower case, in alphabetical order: post_tag and not tag_post. A wrong name fails silently.
bigInteger instead of foreignId A plain integer column guarantees nothing: delete the category and the posts keep an orphan identifier. foreignId('category_id')->constrained() declares a real foreign key.
#[Fillable] raises an error That attribute is specific to Laravel 13. On Laravel 12 and earlier, write the classic property protected $fillable = [...], which behaves identically.
#[Scope] raises an error Same thing: on Laravel 12 the method has to be named scopePublished and be public.
migrate:fresh wipes everything The command drops every table before replaying the migrations. Never on a production database.
Newsletter

New tests, tutorials and projects, by e-mail.

Reproducible tests, versioned code, dated results. Never any spam.