Laravel 13 Tutorial #7: Routen, Controller und Views
geprüft am 7 September 2026 · 8 Min.
Jede Seite bekommt einen Controller, der die Modelle abfragt und eine View zurückgibt. Ergänze Listen immer um with(['category', 'user']): Bei fünf Artikeln senkt Eager Loading die Seite von 11 auf 3 Abfragen.
Die Daten sind da, das Backoffice erlaubt die Eingabe. Dieses Kapitel bringt sie auf den Bildschirm: die Startseite, die Kategorie- und die Tag-Seite sowie die Detailseite eines Artikels.
Die Controller anlegen
Drei Controller haben nur eine einzige Aufgabe, daher --invokable. IndexController existiert bereits seit Kapitel 5: Sein Inhalt wird weiter unten ersetzt. PostController bekommt zwei Methoden: einen Artikel anzeigen und im nächsten Kapitel die Suche verarbeiten.
php artisan make:controller CategoryController --invokable
php artisan make:controller TagController --invokable
php artisan make:controller PostControllerLegen Sie sie an, bevor Sie die Routen schreiben: Eine Route der Form Route::get('/…', CategoryController::class) verlangt, dass die Klasse existiert, sonst startet kein artisan-Befehl mehr.
Die Routen
<?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');Jede Route hat einen Namen: Die Views bauen ihre Links mit route('post', $post), nie mit einer fest verdrahteten Adresse.
Dank getRouteKeyName(), in Kapitel 6 auf den Modellen deklariert, werden die Parameter {category}, {tag} und {post} über die Spalte slug aufgelöst. Laravel lädt den Datensatz und liefert eine 404, wenn nichts passt – noch bevor der Controller überhaupt erreicht wird.
/recherche steht vor den Routen mit Parameter. Hättest du weiter oben Route::get('/{slug}', …) geschrieben, würde diese Route /recherche abfangen und nach einem Artikel mit diesem Slug suchen. Laravel hält bei der ersten passenden Route an: Feste Adressen gehören vor variable.
Der Controller der Startseite
<?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(),
]);
}
}Fünf Methoden brauchen eine Erklärung.
paginate(5)zerlegt die Liste in Seiten zu fünf Artikeln. Wie das funktioniert und wie die Links ausgegeben werden, steht in Kapitel 9.published()ist der Scope aus Kapitel 6. Er ersetztwhere('is_published', true), das sonst in allen fünf Controllern stünde.latest('published_at')sortiert absteigend nach Datum. Ohne Argument sortiert die Methode nachcreated_at, und das ist nicht dasselbe: Ein Artikel, der am Montag geschrieben und am Freitag veröffentlicht wurde, gehört an sein Veröffentlichungsdatum.withCount('posts')hängt jeder Kategorie eine Spalteposts_countan, berechnet über eine Unterabfrage. Genau diese Zahl zeigt die Seitenleiste in Klammern, ohne die Artikel selbst zu laden.with(['category', 'user'])ist der entscheidende Punkt. Dazu gleich mehr.
Das Problem der N+1-Abfragen
Die Artikelliste zeigt zu jedem Artikel den Namen seiner Kategorie und den seines Autors. Ohne Vorkehrung lädt Eloquent zuerst die fünf Artikel, holt dann zu jedem die Kategorie und danach zu jedem den Autor: eine Abfrage für die Liste, dazu zehn weitere. Bei fünfzig Artikeln pro Seite sind es einhunderteins.
with(['category', 'user']) weist Eloquent an, die Relationen im Voraus zu laden, mit einer Abfrage pro Relation. Die Logs der Anwendung zeigen es:
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)Drei Abfragen statt elf, und die Zahl bleibt gleich, wenn die Seite wächst. Das ist die lohnendste Optimierung einer Laravel-Anwendung und zugleich die, die am häufigsten vergessen wird.
Der Kategorie-Controller
<?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(),
]);
}
}Das Argument Category $category ist bereits das geladene Objekt: Laravel hat es aus dem Slug der URL aufgelöst. Du musst keine Abfrage schreiben, und der Fall „Kategorie existiert nicht“ ist erledigt, bevor die Methode beginnt.
$category->posts() mit Klammern liefert eine Query zurück, die sich weiter verfeinern lässt. Ohne Klammern liefert $category->posts direkt die vollständige Collection, ohne Filter und ohne Pagination. Der Unterschied ist grundlegend und wird ständig verwechselt.
Der Tag-Controller
Exakt dasselbe Schema, nur mit 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(),
]);
}
}Der Artikel-Controller
<?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(),
]);
}
}Bei abort_unless($post->is_published, 404) lohnt sich ein Halt. Das Route Model Binding findet den Artikel über seinen Slug, ob veröffentlicht oder nicht: Ohne diese Zeile ist ein Entwurf für jeden sichtbar, der die Adresse errät. Die Zeile liefert eine 404 statt einer 403 und verrät damit nicht einmal, dass der Artikel existiert.
load() ist das Gegenstück zu with() für ein bereits geladenes Objekt. Ohne den Aufruf würde die Ausgabe der Tags eine zusätzliche Abfrage auslösen.
Die Views
Die drei Listenseiten greifen auf das Partial zurück, das in Kapitel 5 entstanden ist.
@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')
@endsectionDie Seite eines Artikels
@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 !!} gibt das HTML aus dem Editor des Backoffice unescaped aus. Hier ist das vertretbar, weil der Inhalt von einem authentifizierten Administrator stammt. Schreib diese Form nie für Daten, die ein Besucher eingegeben hat.
Storage::url() baut die öffentliche Adresse einer hochgeladenen Datei. Das setzt voraus, dass php artisan storage:link in Kapitel 1 ausgeführt wurde, sonst liefert das Bild eine 404.
Prüfen
Alle vier Seiten müssen antworten. Ein Test garantiert das bei jeder künftigen Änderung. Die vollständige Datei mit der Methode makePost(), die einen Testartikel anlegt, steht in Kapitel 9, hier die Methode, die Entwürfe schützt:
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 testDer mit Laravel gelieferte Test tests/Feature/ExampleTest.php lädt die Startseite ohne Datenbank: Seit die Startseite die Tabelle posts abfragt, scheitert er mit no such table: posts. Fügen Sie ihm das Trait RefreshDatabase hinzu, wie in den Tests dieses Tutorials, oder löschen Sie ihn.
Das nächste Kapitel ergänzt die Suchfunktion.