Kurs Laravel 13 #7: trasy, kontrolery i widoki Blade
zweryfikowano 7 września 2026 · 8 min
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.
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.
php artisan make:controller CategoryController --invokable
php artisan make:controller TagController --invokable
php artisan make:controller PostControllerUtwórz je przed zapisaniem tras: trasa w postaci Route::get('/…', CategoryController::class) wymaga istnienia klasy, inaczej żadne polecenie artisan już się nie uruchomi.
Trasy
<?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.
/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
<?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ępujewhere('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 pocreated_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:
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
<?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:
<?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
<?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.
@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@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')
@endsectionStrona artykułu
@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:
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();
}php artisan testTest 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ę.