Kurs Laravel 13 #6: modele Eloquent i panel administracyjny
zweryfikowano 7 września 2026 · 10 min
Utwórz tabele poleceniem php artisan make:model Post -m, zadeklaruj relacje w modelach, a potem wygeneruj ekrany zaplecza przez php artisan make:filament-resource Post --generate, które czyta strukturę tabel i pisze formularz za ciebie. Laravel 13 pozwala konfigurować modele atrybutami PHP #[Fillable] i #[Scope].
Ten rozdział tworzy tabele bloga, modele Eloquent, które je reprezentują, relacje między nimi oraz ekrany zaplecza, dzięki którym w końcu napiszesz artykuły.
Struktura bazy danych
Prosty blog potrzebuje czterech tabel. Piąta, zwana tabelą pivot, łączy artykuły z tagami.
| Tabela | Rola |
|---|---|
users | Autorzy. Dostarczana przez Laravela. |
categories | Jedna kategoria na artykuł. |
tags | Tagi, po kilka na artykuł. |
posts | Artykuły. |
post_tag | Tabela pivot między artykułami a tagami. |
Relacje
- Użytkownik ma wiele artykułów, artykuł należy do użytkownika.
- Kategoria ma wiele artykułów, artykuł należy do kategorii.
- Artykuł ma wiele tagów, a tag należy do wielu artykułów: to relacja „wiele do wielu”, stąd tabela pivot.
Czwarta forma, tutaj nieużywana
Eloquent zna też relację „jeden do jednego”: użytkownik ma profil, i tylko jeden. Zapisuje się ją przez hasOne po jednej stronie i belongsTo po drugiej:
// 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);
}Ten blog jej nie potrzebuje, ale forma wraca regularnie, gdy tylko aplikacja urośnie. Relacje polimorficzne, bardziej złożone, opisuje dokumentacja Eloquenta.
Nazwa tabeli pivot nie jest dowolna. Eloquent wyprowadza ją, zapisując obie nazwy modeli w liczbie pojedynczej, małymi literami, w kolejności alfabetycznej, rozdzielone podkreśleniem: post i tag dają post_tag. Nazwij ją tag_post, a relacja nic nie znajdzie, i to bez czytelnego komunikatu o błędzie.
Tworzenie modeli i migracji
Opcja -m tworzy model i jego migrację za jednym zamachem:
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_tableMigracja kategorii
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() tworzy automatycznie inkrementowany klucz główny. unique() gwarantuje na poziomie bazy, że żadna kategoria nie będzie dzielić sluga z inną, to solidniejsza ochrona niż sprawdzenie po stronie PHP. nullable() dopuszcza pustą wartość. timestamps() dodaje created_at i updated_at, które Eloquent aktualizuje sam.
Migracja tagów jest identyczna, z tags zamiast categories.
Migracja artykułów
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();
});
}Starsze wersje tego kursu zapisywały $table->bigInteger('category_id'). Kolumna istniała, ale nic nie gwarantowało, że wskazuje na realną kategorię: po usunięciu kategorii artykuły zostawały z osieroconym identyfikatorem.
foreignId('category_id')->constrained() deklaruje prawdziwy klucz obcy. constrained() wyprowadza nazwę tabeli z nazwy kolumny, a cascadeOnDelete() usuwa artykuły, gdy znika ich kategoria. Jeśli to zbyt brutalne, nullOnDelete() zamiast tego czyści kolumnę.
Migracja tabeli pivot
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']);
});
}Złożony klucz główny nie pozwala przypiąć dwa razy tego samego tagu do tego samego artykułu. Tabela pivot nie ma ani id(), ani timestamps(): niesie wyłącznie powiązanie.
Uruchomienie migracji
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 DONEKolejność ma znaczenie: posts odwołuje się do categories, więc ta migracja musi wykonać się później. Laravel przetwarza je w kolejności alfabetycznej nazw plików, a te zaczynają się od znacznika czasu, wystarczy utworzyć je we właściwej kolejności.
Żeby na etapie nauki zacząć od zera, php artisan migrate:fresh usuwa wszystkie tabele i odtwarza całość. Nigdy nie uruchamiaj tego polecenia na bazie produkcyjnej.
Modele
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 wprowadza atrybuty PHP do konfiguracji modeli: #[Fillable], #[Hidden], #[Table], #[ObservedBy] i około dwudziestu innych. #[Scope] istnieje natomiast od Laravel 12.5. Model User dostarczany z frameworkiem już z nich korzysta.
Klasyczna forma pozostaje poprawna i działa identycznie. W Laravel 12 lub starszym napisz:
protected $fillable = ['name', 'slug', 'description'];Ta lista chroni przed masowym przypisaniem: bez niej złośliwy formularz mógłby zapisać dowolną kolumnę, w tym is_published.
getRouteKeyName() mówi wiązaniu modelu z rozdziału 2, żeby szukało po kolumnie slug, a nie po id. To dzięki niemu działa /categorie/laravel zamiast /categorie/1.
Tag
Identyczny jak Category, poza relacją, która jest typu „wiele do wielu”:
<?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() konwertuje wartości po drodze: SQLite przechowuje wartości logiczne jako 0 i 1, a cast oddaje je PHP jako true i false. published_at staje się obiektem daty, co pozwala pisać w widokach $post->published_at->translatedFormat('j F Y').
Scope’y to filtry wielokrotnego użytku. Zamiast powtarzać where('is_published', true) w każdym kontrolerze, piszesz:
Post::published()->get();
Post::published()->featured()->get();Atrybut #[Scope] istnieje od Laravel 12.5. W starszej wersji metoda musi nazywać się scopePublished i być publiczna: Laravel usuwa wtedy przedrostek scope przy wywołaniu. Obie formy dają dokładnie to samo zapytanie.
User
Dodaj relację odwrotną do istniejącego modelu:
use Illuminate\Database\Eloquent\Relations\HasMany;
public function posts(): HasMany
{
return $this->hasMany(Post::class);
}Zaplecze
To tutaj Filament zmienia reguły gry w porównaniu z Voyagerem. Jedno polecenie czyta strukturę twoich tabel i pisze odpowiadające jej ekrany:
php artisan make:filament-resource Category --generate
php artisan make:filament-resource Tag --generate
php artisan make:filament-resource Post --generateOpcja --generate wykonuje całą pracę: analizuje kolumny i wnioskuje z nich typ każdego pola. boolean staje się przełącznikiem, timestamp selektorem daty, a kolumna category_id listą rozwijaną powiązaną z tabelą categories.
W katalogu app/Filament/Resources pojawia się sześć plików na zasób:
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.phpPrzeładuj /admin: w menu pojawiły się pozycje „Categories”, „Tags” i „Posts”, a wraz z nimi w pełni działające ekrany listy, tworzenia i edycji. Żadnego klikania w konfiguracji, żadnej tabeli konfiguracyjnej w bazie.
Dopracowanie formularza artykułów
Wygenerowany formularz działa, ale jest surowy: każe wpisywać sluga ręcznie i pomija tagi. Otwórz PostForm.php i zastąp jego zawartość:
<?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()),
]);
}
}Cztery dodatki zmieniają codzienne użytkowanie. live(onBlur: true) z afterStateUpdated automatycznie wypełnia slug po opuszczeniu pola tytułu. Select::make('tags')->relationship('tags', 'name')->multiple() samodzielnie obsługuje tabelę pivot, bez dodatkowej linii kodu. RichEditor zastępuje surowe pole tekstowe sformatowanym edytorem. Wreszcie default(now()) wstępnie wypełnia datę publikacji: bez niego artykuł zapisany bez daty ląduje na ostatniej stronie strony głównej, która sortuje po published_at.
Zrób to samo w CategoryForm i TagForm, z automatycznym slugiem na polu name.
Dane do pracy
Zamiast wpisywać ręcznie dwanaście artykułów, żeby przetestować paginację, napisz 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 sprawia, że seeder da się uruchomić ponownie bez tworzenia duplikatów. sync() zastępuje tagi artykułu podaną listą, zapisując ją do tabeli pivot.
Hasło trafia tam otwartym tekstem: cast 'password' => 'hashed', obecny domyślnie w modelu User, hashuje je przy zapisie.
Następny rozdział podłącza te dane do stron publicznej części serwisu.