Hoofdstuk 7 van 9

Laravel 13 tutorial #7: routes, controllers en views maken

geverifieerd op 7 september 2026 · 8 min

Kort antwoord

Elke pagina krijgt een controller die de modellen bevraagt en een view teruggeeft. Zet altijd with(['category', 'user']) op je lijsten: bij vijf artikelen gaat de pagina dankzij eager loading van 11 queries naar 3.

Aan het einde van dit hoofdstuk, tonen je home-, categorie-, tag- en artikelpagina je gegevens, zonder één overbodige query.

De gegevens staan er en via het back-office voer je ze in. Dit hoofdstuk zet ze op het scherm: de homepage, de categorie- en tagpagina’s, en de detailpagina van een artikel.

De controllers aanmaken

Drie controllers hebben maar één ding te doen, vandaar --invokable. IndexController bestaat al sinds hoofdstuk 5: de inhoud ervan wordt verderop vervangen. PostController krijgt twee methoden: een artikel tonen en in het volgende hoofdstuk de zoekopdracht verwerken.

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

Maak ze aan voordat je de routes schrijft: een route van de vorm Route::get('/…', CategoryController::class) vereist dat de klasse bestaat, anders start geen enkel artisan-commando meer.

De routes

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

Elke route heeft een naam: de views bouwen hun links met route('post', $post), nooit met een hardgecodeerd adres.

Dankzij getRouteKeyName(), in hoofdstuk 6 op de modellen gezet, worden de parameters {category}, {tag} en {post} op de kolom slug opgelost. Laravel laadt het record en geeft een 404 terug als er niets past, nog voordat de controller aan de beurt komt.

De volgorde van je routes telt

/recherche staat vóór de routes met een parameter. Had je hierboven Route::get('/{slug}', …) gezet, dan zou die route /recherche opvangen en een artikel met die slug gaan zoeken. Laravel stopt bij de eerste route die past: vaste adressen horen vóór variabele.

De controller van de homepage

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

Vijf methodes verdienen uitleg.

  • paginate(5) knipt de lijst op in pagina’s van vijf artikelen. Hoe dat werkt en hoe je de links toont, staat in hoofdstuk 9.
  • published() is de scope uit hoofdstuk 6. Hij vervangt where('is_published', true), dat anders in alle vijf de controllers zou staan.
  • latest('published_at') sorteert aflopend op datum. Zonder argument sorteert de methode op created_at, en dat is iets anders: een artikel dat je maandag schrijft en vrijdag publiceert, hoort op zijn publicatiedatum te staan.
  • withCount('posts') hangt aan elke categorie een kolom posts_count, berekend met een subquery. Dat is het getal dat de zijbalk tussen haakjes toont, zonder de artikelen zelf te laden.
  • with(['category', 'user']) is het belangrijkste punt. Zie hieronder.

Het N+1-queryprobleem

De artikellijst toont bij elk artikel de naam van zijn categorie en die van zijn auteur. Zonder voorzorg laadt Eloquent eerst de vijf artikelen, haalt daarna per artikel de categorie op en vervolgens per artikel de auteur: één query voor de lijst, plus tien. Bij vijftig artikelen per pagina worden dat 101 queries.

with(['category', 'user']) vraagt Eloquent om de relaties vooraf te laden, in één query per relatie. De logs van de applicatie laten het zien:

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)

Drie queries in plaats van elf, en dat aantal beweegt niet mee als de pagina groeit. Dit is de meest rendabele optimalisatie van een Laravel-applicatie, en meteen ook de meest vergeten.

De categoriecontroller

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

Het argument Category $category is al het geladen object: Laravel heeft het uit de slug in de URL opgelost. Je hoeft geen enkele query te schrijven, en het geval “categorie bestaat niet” is afgehandeld voordat de methode begint.

$category->posts() mét haakjes geeft een query terug die je verder kunt verfijnen. Zonder haakjes levert $category->posts meteen de volledige collectie op, die je niet meer kunt filteren of pagineren. Dat verschil is fundamenteel en een klassieke bron van verwarring.

De tagcontroller

Precies hetzelfde schema, met 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(),
        ]);
    }
}

De artikelcontroller

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

Bij abort_unless($post->is_published, 404) loont het even stil te staan. Route model binding vindt het artikel op zijn slug, gepubliceerd of niet: zonder die regel is een concept leesbaar voor iedereen die het adres raadt. De regel geeft een 404 in plaats van een 403, en verklapt dus niet eens dat het artikel bestaat.

load() is het equivalent van with() op een object dat al geladen is. Zonder die aanroep zou het tonen van de tags een extra query kosten.

De views

De drie lijstpagina’s hergebruiken de partial die je in hoofdstuk 5 hebt geschreven.

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

De detailpagina van een artikel

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 !!} toont de HTML uit de editor van het back-office zonder die te escapen. Dat mag hier, want de inhoud komt van een ingelogde beheerder. Schrijf deze vorm nooit voor gegevens die een bezoeker heeft ingevuld.

Storage::url() bouwt het publieke adres van een geüpload bestand. Het gaat ervan uit dat je php artisan storage:link in hoofdstuk 1 hebt uitgevoerd, zo niet, dan geeft de afbeelding een 404.

Controleren

Alle vier de pagina’s moeten antwoorden. Een test garandeert dat bij elke toekomstige wijziging. Het volledige bestand, met de methode makePost() die een testartikel aanmaakt, staat in hoofdstuk 9, hier is de methode die concepten beschermt:

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 faalt vanaf dit hoofdstuk

De test die met Laravel wordt meegeleverd, tests/Feature/ExampleTest.php, laadt de homepage zonder database: sinds de homepage de tabel posts bevraagt, faalt hij met no such table: posts. Voeg er de trait RefreshDatabase aan toe, zoals in de tests van deze tutorial, of verwijder hem.

Het volgende hoofdstuk voegt de zoekmachine toe.

Veelgemaakte fouten

Een concept blijft zichtbaar via zijn adres Route model binding vindt het artikel, gepubliceerd of niet. Voeg abort_unless($post->is_published, 404) toe, anders leest iedereen die de URL raadt je concepten.
De haakjes van een relatie vergeten $category->posts geeft de volledige collectie terug, die je niet kunt filteren of pagineren. $category->posts() geeft een query terug die je verder verfijnt.
De pagina wordt trager naarmate ze groeit Dat is het N+1-queryprobleem. with() laadt de relaties vooraf en zet het aantal queries vast.
latest() sorteert op de verkeerde kolom Zonder argument sorteert latest() op created_at. Voor een blog moet je published_at meegeven.
Nieuwsbrief

Nieuwe tests, tutorials en projecten, per e-mail.

Reproduceerbare tests, geversioneerde code, gedateerde resultaten. Nooit spam.