Tutorial Laravel 13 #6: modelos Eloquent e back-office Filament
verificado a 7 Setembro 2026 · 11 min
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].
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:
// 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:
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_tableA migração das categorias
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
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();
});
}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ô
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
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 DONEA 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
<?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';
}
}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:
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»:
<?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';
}
}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:
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:
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:
php artisan make:filament-resource Category --generate
php artisan make:filament-resource Tag --generate
php artisan make:filament-resource Post --generateA 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:
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.phpRecarrega 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:
<?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:
<?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 --seedO 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.