Chapitre 6 sur 9

Tutoriel Laravel 13 #6 : Créer des modèles et configurer le panneau d’administration

vérifié le 7 septembre 2026 · 11 min

Réponse rapide

Créez les tables avec php artisan make:model Post -m, déclarez les relations dans les modèles, puis générez les écrans du back-office avec php artisan make:filament-resource Post --generate, qui lit la structure des tables et écrit le formulaire pour vous. Laravel 13 permet d’écrire la configuration des modèles avec les attributs PHP #[Fillable] et #[Scope].

À la fin de ce chapitre, vous aurez quatre tables, leurs modèles Eloquent avec les relations, et un back-office capable de créer des articles.

Ce chapitre crée les tables du blog, les modèles Eloquent qui les représentent, les relations entre eux, et les écrans du back-office qui permettront enfin de rédiger des articles.

La structure de la base

Un blog simple demande quatre tables. Une cinquième, dite table pivot, relie les articles aux étiquettes.

Table Rôle
users Les auteurs. Fournie par Laravel.
categories Une catégorie par article.
tags Les étiquettes, plusieurs par article.
posts Les articles.
post_tag La table pivot entre articles et étiquettes.

Les relations

  • Un utilisateur a plusieurs articles, un article appartient à un utilisateur.
  • Une catégorie a plusieurs articles, un article appartient à une catégorie.
  • Un article a plusieurs étiquettes et une étiquette appartient à plusieurs articles : c’est une relation « plusieurs à plusieurs », d’où la table pivot.

La quatrième forme, qui ne sert pas ici

Eloquent connaît aussi la relation « un à un » : un utilisateur possède un profil, et un seul. Elle s’écrit avec hasOne d’un côté et belongsTo de l’autre :

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

Ce blog n’en a pas l’usage, mais la forme revient souvent dès qu’une application grandit. Les relations polymorphes, plus complexes, sont décrites dans la documentation d’Eloquent.

Le nom de la table pivot n’est pas libre. Eloquent le devine en mettant les deux noms de modèles au singulier, en minuscules, dans l’ordre alphabétique, séparés par un tiret bas : post et tag donnent post_tag. Nommez-la tag_post et la relation ne trouvera rien, sans message d’erreur explicite.

Créer les modèles et les migrations

L’option -m crée le modèle et sa migration d’un seul geste :

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

La migration des catégories

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() crée une clé primaire auto-incrémentée. unique() garantit qu’aucune catégorie ne partagera son slug avec une autre, au niveau de la base, une protection plus solide qu’une vérification en PHP. nullable() autorise la valeur vide. timestamps() ajoute created_at et updated_at, qu’Eloquent tient à jour tout seul.

La migration des étiquettes est identique, avec tags à la place de categories.

La migration des articles

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 plutôt que bigInteger

Les anciennes versions de ce tutoriel écrivaient $table->bigInteger('category_id'). La colonne existait, mais rien ne garantissait qu’elle pointe vers une catégorie réelle : supprimez la catégorie, les articles gardaient un identifiant orphelin.

foreignId('category_id')->constrained() déclare une vraie clé étrangère. constrained() devine la table depuis le nom de la colonne, et cascadeOnDelete() supprime les articles quand leur catégorie disparaît. Si ce comportement est trop brutal, nullOnDelete() vide la colonne à la place.

La migration de la table pivot

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

La clé primaire composée empêche d’attacher deux fois la même étiquette au même article. La table pivot n’a ni id() ni timestamps() : elle ne porte qu’une association.

Appliquer les 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

L’ordre compte : posts référence categories, sa migration doit donc s’exécuter après. Laravel les traite dans l’ordre alphabétique des noms de fichiers, qui commencent par un horodatage, les créer dans le bon ordre suffit.

Pour repartir de zéro pendant l’apprentissage, php artisan migrate:fresh supprime toutes les tables et rejoue tout. Ne lancez jamais cette commande sur une base de production.

Les modèles

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] est propre à Laravel 13

Laravel 13 introduit des attributs PHP pour configurer les modèles : #[Fillable], #[Hidden], #[Table], #[ObservedBy] et une vingtaine d’autres. #[Scope], lui, existe depuis Laravel 12.5. Le modèle User livré avec le framework les utilise déjà.

La forme classique reste valide et fonctionne à l’identique. Sur Laravel 12 ou antérieur, écrivez :

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

Cette liste protège contre l’affectation de masse : sans elle, un formulaire malveillant pourrait écrire dans n’importe quelle colonne, y compris is_published.

getRouteKeyName() dit à la liaison de modèle du chapitre 2 de chercher sur la colonne slug plutôt que sur id. C’est ce qui rend possible /categorie/laravel au lieu de /categorie/1.

Tag

Identique à Category, à la relation près, qui est « plusieurs à plusieurs » :

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() convertit les valeurs au passage : SQLite stocke les booléens comme 0 et 1, le cast les rend au PHP en true et false. published_at devient un objet date, ce qui permet d’écrire $post->published_at->translatedFormat('j F Y') dans les vues.

Les scopes sont des filtres réutilisables. Au lieu de répéter where('is_published', true) dans chaque contrôleur, on écrit :

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

L’attribut #[Scope] existe depuis Laravel 12.5. Sur une version antérieure, la méthode doit s’appeler scopePublished et être publique : Laravel retire alors le préfixe scope à l’appel. Les deux formes produisent exactement la même requête.

User

Ajoutez la relation inverse au modèle existant :

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

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

Le back-office

C’est ici que Filament change la donne par rapport à Voyager. Une commande lit la structure de vos tables et écrit les écrans correspondants :

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

L’option --generate fait tout le travail : elle inspecte les colonnes et déduit le type de champ de chacune. Un boolean devient un interrupteur, un timestamp un sélecteur de date, une colonne category_id une liste déroulante reliée à la table categories.

Six fichiers par ressource apparaissent dans 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

Rechargez /admin : les entrées « Categories », « Tags » et « Posts » sont apparues dans le menu, avec des écrans de liste, de création et de modification pleinement fonctionnels. Aucune configuration à cliquer, aucune table de configuration en base.

Affiner le formulaire des articles

Le formulaire généré fonctionne mais reste brut : il demande de saisir le slug à la main et ignore les étiquettes. Ouvrez PostForm.php et remplacez son contenu :

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

Quatre ajouts changent l’usage au quotidien. live(onBlur: true) suivi de afterStateUpdated remplit le slug automatiquement quand vous quittez le champ du titre. Select::make('tags')->relationship('tags', 'name')->multiple() gère la table pivot toute seule, sans une ligne de code supplémentaire. RichEditor remplace la zone de texte brute par un éditeur formaté. Enfin default(now()) pré-remplit la date de publication : sans elle, un article enregistré sans date se retrouve en dernière page de l’accueil, qui trie sur published_at.

Faites la même chose pour CategoryForm et TagForm, avec le slug automatique sur le champ name.

Des données pour travailler

Plutôt que de saisir douze articles à la main pour tester la pagination, écrivez un 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 rend le seeder rejouable sans créer de doublons. sync() remplace les étiquettes d’un article par la liste fournie, en écrivant dans la table pivot.

Le mot de passe est passé en clair : le cast 'password' => 'hashed', présent par défaut sur le modèle User, le chiffre à l’enregistrement.

Le chapitre suivant branche ces données sur les pages du site public.

Erreurs fréquentes

La relation « plusieurs à plusieurs » ne renvoie rien Eloquent déduit le nom de la table pivot en mettant les modèles au singulier, en minuscules, dans l’ordre alphabétique : post_tag et non tag_post. Un mauvais nom échoue silencieusement.
bigInteger au lieu de foreignId Une simple colonne entière ne garantit rien : supprimez la catégorie et les articles gardent un identifiant orphelin. foreignId('category_id')->constrained() déclare une vraie clé étrangère.
#[Fillable] provoque une erreur Cet attribut est propre à Laravel 13. Sur Laravel 12 et antérieur, écrivez la propriété classique protected $fillable = [...], qui fonctionne à l’identique.
#[Scope] provoque une erreur Même chose : sur Laravel 12, la méthode doit s’appeler scopePublished et être publique.
migrate:fresh efface tout La commande supprime toutes les tables avant de rejouer les migrations. Jamais sur une base de production.
Newsletter

Les nouveaux tests, tutoriels et projets, par e-mail.

Tests reproductibles, code versionné, résultats datés. Jamais de spam.