Laravel 13 tutorial #7: routes, controllers en views maken
geverifieerd op 7 september 2026 · 8 min
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.
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.
php artisan make:controller CategoryController --invokable
php artisan make:controller TagController --invokable
php artisan make:controller PostControllerMaak 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
<?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.
/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
<?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 vervangtwhere('is_published', true), dat anders in alle vijf de controllers zou staan.latest('published_at')sorteert aflopend op datum. Zonder argument sorteert de methode opcreated_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 kolomposts_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:
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
<?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:
<?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
<?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.
@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')
@endsectionDe detailpagina van een artikel
@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:
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 testDe 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.