Rozdział 6 z 9

Kurs Laravel 13 #6: modele Eloquent i panel administracyjny

zweryfikowano 7 września 2026 · 10 min

Szybka odpowiedź

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

Na koniec tego rozdziału, będziesz mieć cztery tabele, ich modele Eloquent wraz z relacjami oraz zaplecze, w którym utworzysz artykuły.

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:

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

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:

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

Migracja kategorii

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

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

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

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

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

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

Kolejność 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

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] jest specyficzny dla Laravel 13

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:

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

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

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

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

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

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

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

Przeł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ść:

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

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:

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

Częste błędy

Relacja „wiele do wielu” nic nie zwraca Eloquent wyprowadza nazwę tabeli pivot, zapisując modele w liczbie pojedynczej, małymi literami, w kolejności alfabetycznej: post_tag, a nie tag_post. Zła nazwa zawodzi po cichu.
bigInteger zamiast foreignId Zwykła kolumna całkowita niczego nie gwarantuje: usuń kategorię, a artykuły zostaną z osieroconym identyfikatorem. foreignId('category_id')->constrained() deklaruje prawdziwy klucz obcy.
#[Fillable] zgłasza błąd Ten atrybut istnieje tylko w Laravelu 13. W Laravelu 12 i starszym napisz klasyczną właściwość protected $fillable = [...], która działa tak samo.
#[Scope] zgłasza błąd To samo: w Laravelu 12 metoda musi nazywać się scopePublished i być publiczna.
migrate:fresh kasuje wszystko Polecenie usuwa wszystkie tabele, zanim odtworzy migracje. Nigdy na bazie produkcyjnej.
Newsletter

Nowe testy, poradniki i projekty — e-mailem.

Powtarzalne testy, wersjonowany kod, datowane wyniki. Nigdy spamu.