Capítulo 6 de 9

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

verificado a 7 Setembro 2026 · 11 min

Resposta rápida

Cria as tabelas com php artisan make:model Post -m, declara as relações nos modelos e gera depois os ecrãs do back-office com php artisan make:filament-resource Post --generate, que lê a estrutura das tabelas e escreve o formulário por ti. O Laravel 13 permite escrever a configuração dos modelos com os atributos PHP #[Fillable] e #[Scope].

No fim deste capítulo, terás quatro tabelas, os respetivos modelos Eloquent com as relações, e um back-office capaz de criar artigos.

Este capítulo cria as tabelas do blog, os modelos Eloquent que as representam, as relações entre eles e os ecrãs do back-office que vão finalmente permitir-te escrever artigos.

A estrutura da base de dados

Um blog simples precisa de quatro tabelas. Uma quinta, a chamada tabela pivô, liga os artigos às etiquetas.

Tabela Função
users Os autores. Fornecida pelo Laravel.
categories Uma categoria por artigo.
tags As etiquetas, várias por artigo.
posts Os artigos.
post_tag A tabela pivô entre artigos e etiquetas.

As relações

  • Um utilizador tem vários artigos, um artigo pertence a um utilizador.
  • Uma categoria tem vários artigos, um artigo pertence a uma categoria.
  • Um artigo tem várias etiquetas e uma etiqueta pertence a vários artigos: é uma relação «muitos para muitos», daí a tabela pivô.

A quarta forma, que aqui não serve

O Eloquent conhece também a relação «um para um»: um utilizador tem um perfil, e um só. Escreve-se com hasOne de um lado e belongsTo do outro:

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 não precisa dela, mas a forma volta a aparecer assim que uma aplicação cresce. As relações polimórficas, mais complexas, estão descritas na documentação do Eloquent.

O nome da tabela pivô não é livre. O Eloquent adivinha-o pondo os dois nomes de modelos no singular, em minúsculas, por ordem alfabética, separados por um underscore: post e tag dão post_tag. Chama-lhe tag_post e a relação não encontra nada, sem mensagem de erro explícita.

Criar os modelos e as migrações

A opção -m cria o modelo e a respetiva migração de uma só 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

A migração das categorias

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

O id() cria uma chave primária auto-incrementada. O unique() garante que nenhuma categoria partilha o slug com outra, ao nível da base de dados, uma proteção mais sólida do que uma verificação em PHP. O nullable() autoriza o valor vazio. O timestamps() acrescenta created_at e updated_at, que o Eloquent mantém atualizados sozinho.

A migração das etiquetas é idêntica, com tags no lugar de categories.

A migração dos artigos

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 em vez de bigInteger

As versões antigas deste tutorial escreviam $table->bigInteger('category_id'). A coluna existia, mas nada garantia que apontasse para uma categoria real: bastava apagar a categoria para os artigos ficarem com um identificador órfão.

O foreignId('category_id')->constrained() declara uma verdadeira chave estrangeira. O constrained() deduz a tabela a partir do nome da coluna, e o cascadeOnDelete() apaga os artigos quando a categoria desaparece. Se esse comportamento for demasiado brutal, o nullOnDelete() esvazia a coluna em vez disso.

A migração da tabela pivô

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

A chave primária composta impede que a mesma etiqueta seja associada duas vezes ao mesmo artigo. A tabela pivô não tem id() nem timestamps(): transporta apenas uma associação.

Aplicar as migrações

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

A ordem conta: posts referencia categories, por isso a sua migração tem de correr depois. O Laravel trata-as por ordem alfabética dos nomes de ficheiros, que começam por um timestamp, basta criá-las pela ordem certa.

Para recomeçares do zero durante a aprendizagem, o php artisan migrate:fresh apaga todas as tabelas e volta a correr tudo. Nunca lances este comando numa base de dados de produção.

Os 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] é próprio do Laravel 13

O Laravel 13 introduz atributos PHP para configurar os modelos: #[Fillable], #[Hidden], #[Table], #[ObservedBy] e uma vintena de outros. #[Scope], por sua vez, existe desde o Laravel 12.5. O modelo User fornecido com a framework já os utiliza.

A forma clássica continua válida e funciona de forma idêntica. No Laravel 12 ou anterior, escreva:

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

Esta lista protege contra a atribuição em massa: sem ela, um formulário malicioso poderia escrever em qualquer coluna, incluindo is_published.

O getRouteKeyName() diz à ligação de modelo do capítulo 2 para procurar na coluna slug em vez de id. É isso que torna possível /categorie/laravel em vez de /categorie/1.

Tag

Idêntico a Category, tirando a relação, que é «muitos para muitos»:

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

O casts() converte os valores pelo caminho: o SQLite guarda os booleanos como 0 e 1, e o cast devolve-os ao PHP como true e false. O published_at passa a ser um objeto de data, o que permite escrever $post->published_at->translatedFormat('j F Y') nas views.

Os scopes são filtros reutilizáveis. Em vez de repetir where('is_published', true) em cada controlador, escreve-se:

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

O atributo #[Scope] existe desde o Laravel 12.5. Numa versão anterior, o método tem de se chamar scopePublished e ser público: o Laravel retira então o prefixo scope na chamada. As duas formas produzem exatamente a mesma consulta.

User

Acrescenta a relação inversa ao modelo já existente:

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

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

O back-office

É aqui que o Filament muda tudo em relação ao Voyager. Um comando lê a estrutura das tuas tabelas e escreve os ecrãs correspondentes:

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

A opção --generate faz todo o trabalho: inspeciona as colunas e deduz o tipo de campo de cada uma. Um boolean passa a interruptor, um timestamp a seletor de data, uma coluna category_id a lista pendente ligada à tabela categories.

Aparecem seis ficheiros por recurso em 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

Recarrega o /admin: as entradas «Categories», «Tags» e «Posts» apareceram no menu, com ecrãs de listagem, de criação e de edição plenamente funcionais. Nenhuma configuração para clicar, nenhuma tabela de configuração na base de dados.

Afinar o formulário dos artigos

O formulário gerado funciona, mas fica em bruto: obriga a escrever o slug à mão e ignora as etiquetas. Abre o PostForm.php e substitui o seu conteúdo:

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

Quatro acréscimos mudam o uso quotidiano. live(onBlur: true) seguido de afterStateUpdated preenche o slug automaticamente quando sai do campo do título. Select::make('tags')->relationship('tags', 'name')->multiple() trata sozinho da tabela pivot, sem uma linha de código a mais. RichEditor substitui a área de texto simples por um editor formatado. Por fim, default(now()) preenche previamente a data de publicação: sem ela, um artigo gravado sem data acaba na última página da página inicial, que ordena por published_at.

Faz o mesmo no CategoryForm e no TagForm, com o slug automático a partir do campo name.

Dados para trabalhar

Em vez de escreveres doze artigos à mão para testar a paginação, escreve um 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

O firstOrCreate torna o seeder repetível sem criar duplicados. O sync() substitui as etiquetas de um artigo pela lista fornecida, escrevendo na tabela pivô.

A palavra-passe é passada em claro: o cast 'password' => 'hashed', presente por omissão no modelo User, cifra-a ao gravar.

O capítulo seguinte liga estes dados às páginas do site público.

Erros frequentes

A relação «muitos para muitos» não devolve nada O Eloquent deduz o nome da tabela pivô pondo os modelos no singular, em minúsculas, por ordem alfabética: post_tag e não tag_post. Um nome errado falha em silêncio.
bigInteger em vez de foreignId Uma simples coluna inteira não garante nada: apagas a categoria e os artigos ficam com um identificador órfão. O foreignId('category_id')->constrained() declara uma verdadeira chave estrangeira.
#[Fillable] provoca um erro Este atributo é próprio do Laravel 13. Em Laravel 12 e anteriores, escreve a propriedade clássica protected $fillable = [...], que funciona de modo idêntico.
#[Scope] provoca um erro O mesmo: em Laravel 12, o método tem de se chamar scopePublished e ser público.
migrate:fresh apaga tudo O comando elimina todas as tabelas antes de voltar a correr as migrações. Nunca numa base de dados de produção.
Newsletter

Os novos testes, tutoriais e projetos, por e-mail.

Testes reproduzíveis, código versionado, resultados datados. Nunca spam.