Rozdział 7 z 9

Kurs Laravel 13 #7: trasy, kontrolery i widoki Blade

zweryfikowano 7 września 2026 · 8 min

Szybka odpowiedź

Każda strona dostaje kontroler, który odpytuje modele i zwraca widok. Do list zawsze dodawaj with(['category', 'user']): przy pięciu artykułach eager loading zbija stronę z 11 zapytań do 3.

Na koniec tego rozdziału, strona główna oraz strony kategorii, tagu i artykułu wyświetlą twoje dane, bez ani jednego zbędnego zapytania.

Dane są, zaplecze pozwala je wprowadzać. Ten rozdział je wyświetla: stronę główną, strony kategorii i tagu oraz stronę pojedynczego artykułu.

Tworzenie kontrolerów

Trzy kontrolery mają tylko jedno zadanie, stąd --invokable. IndexController istnieje już od rozdziału 5: jego zawartość zostaje zastąpiona poniżej. PostController będzie miał dwie metody: wyświetlanie artykułu i obsługę wyszukiwania w następnym rozdziale.

bash
php artisan make:controller CategoryController --invokable
php artisan make:controller TagController --invokable
php artisan make:controller PostController

Utwórz je przed zapisaniem tras: trasa w postaci Route::get('/…', CategoryController::class) wymaga istnienia klasy, inaczej żadne polecenie artisan już się nie uruchomi.

Trasy

routes/web.php
<?php

use App\Http\Controllers\CategoryController;
use App\Http\Controllers\IndexController;
use App\Http\Controllers\PostController;
use App\Http\Controllers\TagController;
use Illuminate\Support\Facades\Route;

Route::get('/', IndexController::class)->name('home');
Route::get('/recherche', [PostController::class, 'search'])->name('search');
Route::get('/categorie/{category}', CategoryController::class)->name('category');
Route::get('/etiquette/{tag}', TagController::class)->name('tag');
Route::get('/article/{post}', [PostController::class, 'show'])->name('post');

Każda trasa ma nazwę: widoki budują odnośniki przez route('post', $post), nigdy przez adres wpisany na sztywno.

Dzięki getRouteKeyName() zadeklarowanemu w modelach w rozdziale 6 parametry {category}, {tag} i {post} są rozwiązywane po kolumnie slug. Laravel wczytuje rekord i zwraca 404, jeśli nic nie pasuje, jeszcze zanim wejdzie do kontrolera.

Kolejność tras ma znaczenie

/recherche jest zadeklarowana przed trasami z parametrem. Gdyby wyżej stało Route::get('/{slug}', …), przechwyciłoby /recherche i szukało artykułu o takim slugu. Laravel zatrzymuje się na pierwszej pasującej trasie: adresy stałe idą przed adresami zmiennymi.

Kontroler strony głównej

app/Http/Controllers/IndexController.php
<?php

namespace App\Http\Controllers;

use App\Models\Category;
use App\Models\Post;
use App\Models\Tag;
use Illuminate\View\View;

class IndexController extends Controller
{
    public function __invoke(): View
    {
        return view('home', [
            'posts' => Post::published()
                ->with(['category', 'user'])
                ->latest('published_at')
                ->paginate(5),
            'featuredPosts' => Post::published()->featured()
                ->latest('published_at')
                ->take(5)
                ->get(),
            'categories' => Category::withCount('posts')->orderBy('name')->get(),
            'tags' => Tag::orderBy('name')->get(),
            'recentPosts' => Post::published()->latest('published_at')->take(5)->get(),
        ]);
    }
}

Pięć metod wymaga wyjaśnienia.

  • paginate(5) dzieli listę na strony po pięć artykułów. Jak to działa i jak wyświetlić odnośniki stron, opisuje rozdział 9.
  • published() to scope z rozdziału 6. Zastępuje where('is_published', true), które inaczej powtarzałoby się w pięciu kontrolerach.
  • latest('published_at') sortuje malejąco po dacie. Bez argumentu metoda sortuje po created_at, a to nie to samo: artykuł napisany w poniedziałek i opublikowany w piątek ma trafić na listę z datą publikacji.
  • withCount('posts') dokłada każdej kategorii kolumnę posts_count, wyliczaną podzapytaniem. To właśnie ją pasek boczny pokazuje w nawiasie, bez wczytywania samych artykułów.
  • with(['category', 'user']) to najważniejszy punkt. Patrz niżej.

Problem zapytań N+1

Lista artykułów pokazuje przy każdym z nich nazwę kategorii i autora. Bez żadnych środków ostrożności Eloquent wczytuje najpierw pięć artykułów, potem kategorię każdego z nich, a potem autora każdego z nich: jedno zapytanie o listę plus dziesięć kolejnych. Przy pięćdziesięciu artykułach na stronę, sto jeden zapytań.

with(['category', 'user']) każe Eloquentowi wczytać relacje z wyprzedzeniem, jednym zapytaniem na relację. Widać to w logach aplikacji:

sql
select * from "posts" where "is_published" = 1 order by "published_at" desc limit 5 offset 0
select * from "categories" where "categories"."id" in (1, 2)
select * from "users" where "users"."id" in (1)

Trzy zapytania zamiast jedenastu, a ta liczba nie rośnie razem ze stroną. To najbardziej opłacalna optymalizacja w aplikacji Laravela i zarazem najczęściej pomijana.

Kontroler kategorii

app/Http/Controllers/CategoryController.php
<?php

namespace App\Http\Controllers;

use App\Models\Category;
use App\Models\Post;
use App\Models\Tag;
use Illuminate\View\View;

class CategoryController extends Controller
{
    public function __invoke(Category $category): View
    {
        return view('category', [
            'category' => $category,
            'posts' => $category->posts()
                ->published()
                ->with(['category', 'user'])
                ->latest('published_at')
                ->paginate(5),
            'categories' => Category::withCount('posts')->orderBy('name')->get(),
            'tags' => Tag::orderBy('name')->get(),
            'recentPosts' => Post::published()->latest('published_at')->take(5)->get(),
        ]);
    }
}

Argument Category $category to już wczytany obiekt: Laravel rozwiązał go po slugu z adresu. Nie trzeba pisać żadnego zapytania, a przypadek „kategoria nie istnieje” jest załatwiony, zanim wejdziesz do metody.

$category->posts() z nawiasami zwraca zapytanie, które można doprecyzować. Bez nawiasów $category->posts zwróciłoby od razu całą kolekcję, bez filtra i bez paginacji. To rozróżnienie jest kluczowe i bywa częstym źródłem pomyłek.

Kontroler tagu

Dokładnie ten sam schemat, tyle że z Tag:

app/Http/Controllers/TagController.php
<?php

namespace App\Http\Controllers;

use App\Models\Category;
use App\Models\Post;
use App\Models\Tag;
use Illuminate\View\View;

class TagController extends Controller
{
    public function __invoke(Tag $tag): View
    {
        return view('tag', [
            'tag' => $tag,
            'posts' => $tag->posts()
                ->published()
                ->with(['category', 'user'])
                ->latest('published_at')
                ->paginate(5),
            'categories' => Category::withCount('posts')->orderBy('name')->get(),
            'tags' => Tag::orderBy('name')->get(),
            'recentPosts' => Post::published()->latest('published_at')->take(5)->get(),
        ]);
    }
}

Kontroler artykułu

app/Http/Controllers/PostController.php
<?php

namespace App\Http\Controllers;

use App\Models\Category;
use App\Models\Post;
use App\Models\Tag;
use Illuminate\View\View;

class PostController extends Controller
{
    public function show(Post $post): View
    {
        abort_unless($post->is_published, 404);

        $post->load(['category', 'user', 'tags']);

        return view('post', [
            'post' => $post,
            'categories' => Category::withCount('posts')->orderBy('name')->get(),
            'tags' => Tag::orderBy('name')->get(),
            'recentPosts' => Post::published()->latest('published_at')->take(5)->get(),
        ]);
    }
}

abort_unless($post->is_published, 404) zasługuje na chwilę uwagi. Wiązanie modelu znajduje artykuł po slugu niezależnie od tego, czy jest opublikowany: bez tej linii szkic zobaczy każdy, kto zgadnie jego adres. Linia zwraca 404, a nie 403, więc nie zdradza nawet tego, że artykuł istnieje.

load() to odpowiednik with() dla obiektu już wczytanego. Bez niego wyświetlenie tagów wywołałoby dodatkowe zapytanie.

Widoki

Trzy strony z listami korzystają z widoku częściowego napisanego w rozdziale 5.

resources/views/category.blade.php
@extends('layouts.app')

@section('title', $category->name)

@section('content')
    <h1 class="mb-2 text-2xl font-bold">Catégorie : {{ $category->name }}</h1>
    @if ($category->description)
        <p class="mb-6 text-gray-600">{{ $category->description }}</p>
    @endif
    @include('partials.posts-list')
@endsection
resources/views/tag.blade.php
@extends('layouts.app')

@section('title', $tag->name)

@section('content')
    <h1 class="mb-6 text-2xl font-bold">Étiquette : {{ $tag->name }}</h1>
    @include('partials.posts-list')
@endsection

Strona artykułu

resources/views/post.blade.php
@extends('layouts.app')

@section('title', $post->title)
@section('description', $post->excerpt ?: Str::limit(strip_tags($post->content), 150))

@section('content')
    <article class="rounded border bg-white p-6">
        <h1 class="text-3xl font-bold">{{ $post->title }}</h1>
        <p class="mt-2 text-sm text-gray-500">
            {{ $post->user?->name }} —
            <a href="http://%20route('category',%20$post-category)%20" class="hover:underline">{{ $post->category?->name }}</a>
            @if ($post->published_at) — {{ $post->published_at->translatedFormat('j F Y') }} @endif
        </p>

        @if ($post->featured_image)
            <img src="{{ Storage::url($post->featured_image) }}" alt="" class="mt-4 rounded">
        @endif

        <div class="prose mt-6 max-w-none">{!! $post->content !!}</div>

        @if ($post->tags->isNotEmpty())
            <ul class="mt-6 flex flex-wrap gap-2 text-sm">
                @foreach ($post->tags as $tag)
                    <li><a href="http://%20route('tag',%20$tag)%20" class="rounded bg-gray-200 px-2 py-0.5">{{ $tag->name }}</a></li>
                @endforeach
            </ul>
        @endif
    </article>
@endsection

{!! $post->content !!} wyświetla HTML z edytora zaplecza bez escapowania. Tutaj jest to uzasadnione, bo treść pochodzi od zalogowanego administratora. Nigdy nie używaj tej formy do danych wpisanych przez odwiedzającego.

Storage::url() buduje publiczny adres wgranego pliku. Zakłada, że w rozdziale 1 zostało wykonane php artisan storage:link; inaczej obrazek zwróci 404.

Sprawdzenie

Wszystkie cztery strony muszą odpowiadać. Test gwarantuje to przy każdej przyszłej zmianie. Kompletny plik, z metodą makePost() tworzącą testowy artykuł, podany jest w rozdziale 9, oto metoda chroniąca szkice:

tests/Feature/BlogTest.php
public function test_un_article_non_publie_est_invisible(): void
{
    $this->makePost(['is_published' => false]);

    $this->get('/')->assertOk()->assertDontSee('Premier article');
    $this->get('/article/premier-article')->assertNotFound();
}
bash
php artisan test
ExampleTest nie przechodzi od tego rozdziału

Test dostarczany z Laravel, tests/Feature/ExampleTest.php, ładuje stronę główną bez bazy danych: odkąd strona główna odpytuje tabelę posts, kończy się błędem no such table: posts. Dodaj mu cechę RefreshDatabase, jak w testach tego samouczka, albo go usuń.

Następny rozdział dodaje wyszukiwarkę.

Częste błędy

Szkic jest widoczny pod swoim adresem Wiązanie modelu znajduje artykuł niezależnie od tego, czy jest opublikowany. Dodaj abort_unless($post->is_published, 404), inaczej twoje szkice przeczyta każdy, kto zgadnie adres.
Brak nawiasów przy relacji $category->posts zwraca całą kolekcję, której nie da się ani przefiltrować, ani podzielić na strony. $category->posts() zwraca zapytanie, które można doprecyzować.
Strona zwalnia, im więcej na niej treści To problem zapytań N+1. with() wczytuje relacje z wyprzedzeniem i zamraża liczbę zapytań.
latest() sortuje po złej kolumnie Bez argumentu latest() sortuje po created_at. Dla bloga trzeba podać published_at.
Newsletter

Nowe testy, poradniki i projekty — e-mailem.

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