Laravel 13 tutorial #6: Eloquent models and admin panel
verified on 7 September 2026 · 10 min
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].
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:
// 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:
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_tableThe categories migration
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
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();
});
}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
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
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 DONEOrder 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
<?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 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:
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”:
<?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() 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:
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:
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:
php artisan make:filament-resource Category --generate
php artisan make:filament-resource Tag --generate
php artisan make:filament-resource Post --generateThe --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:
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.phpReload /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:
<?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:
<?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 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.