Tutoriel Laravel 13 #6 : Créer des modèles et configurer le panneau d’administration
vérifié le 7 septembre 2026 · 11 min
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].
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 :
// 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 :
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_tableLa migration des catégories
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
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();
});
}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
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
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 DONEL’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
<?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 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 :
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 » :
<?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() 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 :
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 :
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 :
php artisan make:filament-resource Category --generate
php artisan make:filament-resource Tag --generate
php artisan make:filament-resource Post --generateL’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 :
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.phpRechargez /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 :
<?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 :
<?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 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.