Capitolo 6 di 9

Tutorial Laravel 13 #6: modelli Eloquent e back-office Filament

verificato il 7 Settembre 2026 · 10 min

Risposta rapida

Crea le tabelle con php artisan make:model Post -m, dichiara le relazioni nei modelli, poi genera le schermate del back-office con php artisan make:filament-resource Post --generate, che legge la struttura delle tabelle e scrive il form al posto tuo. Laravel 13 permette di scrivere la configurazione dei modelli con gli attributi PHP #[Fillable] e #[Scope].

Alla fine di questo capitolo, avrai quattro tabelle, i loro modelli Eloquent con le relazioni e un back-office capace di creare articoli.

Questo capitolo crea le tabelle del blog, i modelli Eloquent che le rappresentano, le relazioni tra di loro e le schermate del back-office che ti permetteranno finalmente di scrivere articoli.

La struttura del database

Un blog semplice richiede quattro tabelle. Una quinta, detta tabella pivot, collega gli articoli ai tag.

Tabella Ruolo
users Gli autori. Fornita da Laravel.
categories Una categoria per articolo.
tags I tag, più di uno per articolo.
posts Gli articoli.
post_tag La tabella pivot tra articoli e tag.

Le relazioni

  • Un utente ha più articoli, un articolo appartiene a un utente.
  • Una categoria ha più articoli, un articolo appartiene a una categoria.
  • Un articolo ha più tag e un tag appartiene a più articoli: è una relazione «molti a molti», da cui la tabella pivot.

La quarta forma, quella che qui non serve

Eloquent conosce anche la relazione «uno a uno»: un utente possiede un profilo, e uno solo. Si scrive con hasOne da una parte e belongsTo dall’altra:

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

Questo blog non ne ha bisogno, ma la forma torna spesso non appena un’applicazione cresce. Le relazioni polimorfiche, più complesse, sono descritte nella documentazione di Eloquent.

Il nome della tabella pivot non è libero. Eloquent lo deduce mettendo i due nomi dei modelli al singolare, in minuscolo, in ordine alfabetico, separati da un trattino basso: post e tag danno post_tag. Chiamala tag_post e la relazione non troverà niente, senza alcun messaggio di errore esplicito.

Creare i modelli e le migrazioni

L’opzione -m crea il modello e la sua migrazione in un colpo solo:

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 migrazione delle categorie

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 chiave primaria auto-incrementale. unique() garantisce che nessuna categoria condivida lo slug con un’altra, a livello di database, una protezione più solida di un controllo scritto in PHP. nullable() autorizza il valore vuoto. timestamps() aggiunge created_at e updated_at, che Eloquent tiene aggiornati da solo.

La migrazione dei tag è identica, con tags al posto di categories.

La migrazione degli articoli

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 invece di bigInteger

Le vecchie versioni di questo tutorial scrivevano $table->bigInteger('category_id'). La colonna esisteva, ma niente garantiva che puntasse a una categoria reale: cancellata la categoria, gli articoli conservavano un identificatore orfano.

foreignId('category_id')->constrained() dichiara una vera chiave esterna. constrained() deduce la tabella dal nome della colonna e cascadeOnDelete() elimina gli articoli quando la loro categoria sparisce. Se questo comportamento è troppo brutale, nullOnDelete() svuota la colonna al suo posto.

La migrazione della tabella 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 chiave primaria composta impedisce di associare due volte lo stesso tag allo stesso articolo. La tabella pivot non ha né id()timestamps(): porta soltanto un’associazione.

Applicare le migrazioni

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’ordine conta: posts fa riferimento a categories, quindi la sua migrazione deve essere eseguita dopo. Laravel le tratta in ordine alfabetico di nome file, e i nomi iniziano con un timestamp: basta crearle nell’ordine giusto.

Per ripartire da zero mentre impari, php artisan migrate:fresh elimina tutte le tabelle e riesegue tutto. Non lanciare mai questo comando su un database di produzione.

I modelli

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] è proprio di Laravel 13

Laravel 13 introduce attributi PHP per configurare i modelli: #[Fillable], #[Hidden], #[Table], #[ObservedBy] e una ventina di altri. #[Scope], invece, esiste da Laravel 12.5. Il modello User fornito con il framework li usa già.

La forma classica resta valida e funziona in modo identico. Su Laravel 12 o precedente, scrivete:

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

Questa lista protegge dall’assegnazione di massa: senza di essa, un form malevolo potrebbe scrivere in qualsiasi colonna, compresa is_published.

getRouteKeyName() dice al model binding del capitolo 2 di cercare sulla colonna slug invece che su id. È questo che rende possibile /categorie/laravel al posto di /categorie/1.

Tag

Identico a Category, tranne che per la relazione, che è «molti a molti»:

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() converte i valori al volo: SQLite memorizza i booleani come 0 e 1, il cast li restituisce a PHP come true e false. published_at diventa un oggetto data, il che permette di scrivere $post->published_at->translatedFormat('j F Y') nelle viste.

Gli scope sono filtri riutilizzabili. Invece di ripetere where('is_published', true) in ogni controller, si scrive:

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

L’attributo #[Scope] esiste da Laravel 12.5. Su una versione precedente, il metodo deve chiamarsi scopePublished ed essere pubblico: Laravel toglie allora il prefisso scope alla chiamata. Le due forme producono esattamente la stessa query.

User

Aggiungi la relazione inversa al modello esistente:

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

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

Il back-office

È qui che Filament cambia le carte in tavola rispetto a Voyager. Un comando legge la struttura delle tue tabelle e scrive le schermate corrispondenti:

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

L’opzione --generate fa tutto il lavoro: ispeziona le colonne e deduce da ognuna il tipo di campo. Un boolean diventa un interruttore, un timestamp un selettore di data, una colonna category_id un menu a tendina collegato alla tabella categories.

Per ogni risorsa compaiono sei file in 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

Ricarica /admin: nel menu sono comparse le voci «Categories», «Tags» e «Posts», con schermate di elenco, creazione e modifica pienamente funzionanti. Nessuna configurazione da cliccare, nessuna tabella di configurazione nel database.

Rifinire il form degli articoli

Il form generato funziona ma resta grezzo: chiede di scrivere lo slug a mano e ignora i tag. Apri PostForm.php e sostituisci il suo contenuto:

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

Quattro aggiunte cambiano l’uso quotidiano. live(onBlur: true) seguito da afterStateUpdated compila lo slug automaticamente quando lasciate il campo del titolo. Select::make('tags')->relationship('tags', 'name')->multiple() gestisce da solo la tabella pivot, senza una riga di codice in più. RichEditor sostituisce l’area di testo grezza con un editor formattato. Infine default(now()) precompila la data di pubblicazione: senza di essa, un articolo salvato senza data finisce nell’ultima pagina della home, che ordina per published_at.

Fai lo stesso per CategoryForm e TagForm, con lo slug automatico sul campo name.

Dei dati su cui lavorare

Invece di inserire a mano dodici articoli per provare la paginazione, scrivi 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 rende il seeder rieseguibile senza creare duplicati. sync() sostituisce i tag di un articolo con la lista fornita, scrivendo nella tabella pivot.

La password è passata in chiaro: il cast 'password' => 'hashed', presente per impostazione predefinita sul modello User, la cifra al salvataggio.

Il capitolo successivo collega questi dati alle pagine del sito pubblico.

Errori frequenti

La relazione «molti a molti» non restituisce niente Eloquent deduce il nome della tabella pivot mettendo i modelli al singolare, in minuscolo, in ordine alfabetico: post_tag e non tag_post. Un nome sbagliato fallisce in silenzio.
bigInteger al posto di foreignId Una semplice colonna intera non garantisce niente: cancella la categoria e gli articoli conservano un identificatore orfano. foreignId('category_id')->constrained() dichiara una vera chiave esterna.
#[Fillable] provoca un errore Questo attributo esiste solo in Laravel 13. Su Laravel 12 e precedenti, scrivi la proprietà classica protected $fillable = [...], che funziona in modo identico.
#[Scope] provoca un errore Stessa cosa: su Laravel 12 il metodo deve chiamarsi scopePublished ed essere pubblico.
migrate:fresh cancella tutto Il comando elimina tutte le tabelle prima di rieseguire le migrazioni. Mai su un database di produzione.
Newsletter

I nuovi test, tutorial e progetti, via e-mail.

Test riproducibili, codice versionato, risultati datati. Mai spam.