Capítulo 6 de 9

Tutorial Laravel 13 #6: modelos Eloquent y panel de administración

verificado el 7 septiembre 2026 · 11 min

Respuesta rápida

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].

Al final de este capítulo, tendrás cuatro tablas, sus modelos Eloquent con las relaciones, y un back-office capaz de crear artículos.

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:

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

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:

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 migración de las categorías

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() 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

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 en lugar de bigInteger

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

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

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

El 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

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] es propio de Laravel 13

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:

php
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»:

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() 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:

php
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:

app/Models/User.php
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:

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

La 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:

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

Recarga /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:

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

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:

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 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.

Errores frecuentes

La relación «muchos a muchos» no devuelve nada Eloquent deduce el nombre de la tabla pivote poniendo los modelos en singular, en minúsculas y por orden alfabético: post_tag y no tag_post. Un nombre equivocado falla en silencio.
bigInteger en lugar de foreignId Una simple columna entera no garantiza nada: borra la categoría y los artículos se quedan con un identificador huérfano. foreignId('category_id')->constrained() declara una clave foránea de verdad.
#[Fillable] provoca un error Este atributo es propio de Laravel 13. En Laravel 12 y anteriores, escribe la propiedad clásica protected $fillable = [...], que funciona igual.
#[Scope] provoca un error Lo mismo: en Laravel 12 el método debe llamarse scopePublished y ser público.
migrate:fresh lo borra todo El comando elimina todas las tablas antes de volver a ejecutar las migraciones. Nunca sobre una base de datos de producción.
Newsletter

Las nuevas pruebas, tutoriales y proyectos, por correo.

Pruebas reproducibles, código versionado, resultados fechados. Nunca spam.