Tutorial Laravel 13 #6: modelos Eloquent y panel de administración
verificado el 7 septiembre 2026 · 11 min
Crea las tablas con php artisan make:model Post -m, declara las relaciones en los modelos y genera después las pantallas del back-office con php artisan make:filament-resource Post --generate, que lee la estructura de las tablas y escribe el formulario por ti. Laravel 13 permite configurar los modelos con los atributos PHP #[Fillable] y #[Scope].
Este capítulo crea las tablas del blog, los modelos Eloquent que las representan, las relaciones entre ellos y las pantallas del back-office que por fin te permitirán redactar artículos.
La estructura de la base de datos
Un blog sencillo necesita cuatro tablas. Una quinta, llamada tabla pivote, relaciona los artículos con las etiquetas.
| Tabla | Función |
|---|---|
users | Los autores. La trae Laravel. |
categories | Una categoría por artículo. |
tags | Las etiquetas, varias por artículo. |
posts | Los artículos. |
post_tag | La tabla pivote entre artículos y etiquetas. |
Las relaciones
- Un usuario tiene varios artículos, un artículo pertenece a un usuario.
- Una categoría tiene varios artículos, un artículo pertenece a una categoría.
- Un artículo tiene varias etiquetas y una etiqueta pertenece a varios artículos: es una relación «muchos a muchos», de ahí la tabla pivote.
La cuarta forma, que aquí no se usa
Eloquent conoce además la relación «uno a uno»: un usuario tiene un perfil, y solo uno. Se escribe con hasOne por un lado y belongsTo por el otro:
// 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);
}Este blog no la necesita, pero la forma reaparece en cuanto una aplicación crece. Las relaciones polimórficas, más complejas, están descritas en la documentación de Eloquent.
El nombre de la tabla pivote no es libre. Eloquent lo deduce poniendo los dos nombres de modelo en singular, en minúsculas y por orden alfabético, separados por un guion bajo: post y tag dan post_tag. Llámala tag_post y la relación no encontrará nada, sin ningún mensaje de error explícito.
Crear los modelos y las migraciones
La opción -m crea el modelo y su migración de una sola vez:
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 migración de las categorías
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() crea una clave primaria autoincremental. unique() garantiza que ninguna categoría comparta su slug con otra, a nivel de base de datos: una protección más sólida que una comprobación en PHP. nullable() permite el valor vacío. timestamps() añade created_at y updated_at, que Eloquent mantiene al día por su cuenta.
La migración de las etiquetas es idéntica, con tags en lugar de categories.
La migración de los artículos
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();
});
}Las versiones antiguas de este tutorial escribían $table->bigInteger('category_id'). La columna existía, pero nada garantizaba que apuntara a una categoría real: al borrar la categoría, los artículos se quedaban con un identificador huérfano.
foreignId('category_id')->constrained() declara una clave foránea de verdad. constrained() deduce la tabla a partir del nombre de la columna, y cascadeOnDelete() borra los artículos cuando desaparece su categoría. Si ese comportamiento te parece demasiado brutal, nullOnDelete() vacía la columna en su lugar.
La migración de la tabla pivote
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 clave primaria compuesta impide adjuntar dos veces la misma etiqueta al mismo artículo. La tabla pivote no lleva ni id() ni timestamps(): solo guarda una asociación.
Aplicar las migraciones
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 DONEEl orden importa: posts referencia a categories, así que su migración tiene que ejecutarse después. Laravel las procesa por orden alfabético de nombre de fichero, y esos nombres empiezan por una marca de tiempo: basta con crearlas en el orden correcto.
Para empezar de cero mientras aprendes, php artisan migrate:fresh borra todas las tablas y vuelve a ejecutarlo todo. No lances nunca este comando sobre una base de datos de producción.
Los modelos
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 introduce atributos PHP para configurar los modelos: #[Fillable], #[Hidden], #[Table], #[ObservedBy] y una veintena más. #[Scope], por su parte, existe desde Laravel 12.5. El modelo User incluido con el framework ya los utiliza.
La forma clásica sigue siendo válida y funciona igual. En Laravel 12 o anterior, escriba:
protected $fillable = ['name', 'slug', 'description'];Esta lista protege contra la asignación masiva: sin ella, un formulario malicioso podría escribir en cualquier columna, incluida is_published.
getRouteKeyName() le dice al route model binding del capítulo 2 que busque por la columna slug en lugar de por id. Es lo que hace posible /categorie/laravel en lugar de /categorie/1.
Tag
Idéntico a Category, salvo por la relación, que es «muchos a muchos»:
<?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() convierte los valores al vuelo: SQLite almacena los booleanos como 0 y 1, y el cast se los devuelve a PHP como true y false. published_at pasa a ser un objeto de fecha, lo que permite escribir $post->published_at->translatedFormat('j F Y') en las vistas.
Los scopes son filtros reutilizables. En lugar de repetir where('is_published', true) en cada controlador, se escribe:
Post::published()->get();
Post::published()->featured()->get();El atributo #[Scope] existe desde Laravel 12.5. En una versión anterior, el método debe llamarse scopePublished y ser público: Laravel retira entonces el prefijo scope al invocarlo. Las dos formas producen exactamente la misma consulta.
User
Añade la relación inversa al modelo existente:
use Illuminate\Database\Eloquent\Relations\HasMany;
public function posts(): HasMany
{
return $this->hasMany(Post::class);
}El back-office
Aquí es donde Filament marca la diferencia frente a Voyager. Un solo comando lee la estructura de tus tablas y escribe las pantallas correspondientes:
php artisan make:filament-resource Category --generate
php artisan make:filament-resource Tag --generate
php artisan make:filament-resource Post --generateLa opción --generate hace todo el trabajo: inspecciona las columnas y deduce el tipo de campo de cada una. Un boolean se convierte en un interruptor, un timestamp en un selector de fecha y una columna category_id en una lista desplegable enlazada con la tabla categories.
Aparecen seis ficheros por recurso en 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.phpRecarga /admin: las entradas «Categories», «Tags» y «Posts» han aparecido en el menú, con pantallas de listado, creación y edición plenamente funcionales. Sin configurar nada a golpe de clic, sin tablas de configuración en la base de datos.
Afinar el formulario de los artículos
El formulario generado funciona, pero se queda en lo básico: obliga a escribir el slug a mano e ignora las etiquetas. Abre PostForm.php y sustituye su contenido:
<?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()),
]);
}
}Cuatro añadidos cambian el uso cotidiano. live(onBlur: true) seguido de afterStateUpdated rellena el slug automáticamente cuando sale del campo del título. Select::make('tags')->relationship('tags', 'name')->multiple() gestiona la tabla pivote por sí solo, sin una línea de código más. RichEditor sustituye el área de texto en bruto por un editor con formato. Por último, default(now()) rellena de antemano la fecha de publicación: sin ella, un artículo guardado sin fecha acaba en la última página del inicio, que ordena por published_at.
Haz lo mismo con CategoryForm y TagForm, con el slug automático sobre el campo name.
Datos con los que trabajar
En lugar de teclear doce artículos a mano para probar la paginación, escribe 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 hace que el seeder se pueda relanzar sin crear duplicados. sync() sustituye las etiquetas de un artículo por la lista que le pasas, escribiendo en la tabla pivote.
La contraseña se pasa en claro: el cast 'password' => 'hashed', presente por defecto en el modelo User, la cifra al guardar.
El capítulo siguiente conecta estos datos con las páginas del sitio público.