Kapitel 7 von 9

Laravel 13 Tutorial #7: Routen, Controller und Views

geprüft am 7 September 2026 · 8 Min.

Schnelle Antwort

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.

Am Ende dieses Kapitels, zeigen Start-, Kategorie-, Tag- und Artikelseite deine Daten an, ohne eine überflüssige Abfrage.

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.

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

Legen 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

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

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.

Die Reihenfolge der Routen zählt

/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

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

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 ersetzt where('is_published', true), das sonst in allen fünf Controllern stünde.
  • latest('published_at') sortiert absteigend nach Datum. Ohne Argument sortiert die Methode nach created_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 Spalte posts_count an, 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:

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)

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

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

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:

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

Der Artikel-Controller

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

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.

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

Die Seite eines Artikels

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 !!} 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:

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 schlägt ab diesem Kapitel fehl

Der 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.

Häufige Fehler

Ein Entwurf bleibt über seine Adresse sichtbar Das Route Model Binding findet den Artikel, ob veröffentlicht oder nicht. Ergänze abort_unless($post->is_published, 404), sonst liest jeder deine Entwürfe, der die URL errät.
Klammern an einer Relation vergessen $category->posts liefert die vollständige Collection, die sich weder filtern noch paginieren lässt. $category->posts() liefert eine Query, die sich verfeinern lässt.
Die Seite wird langsamer, je größer sie wird Das ist das Problem der N+1-Abfragen. with() lädt die Relationen im Voraus und friert die Zahl der Abfragen ein.
latest() sortiert nach der falschen Spalte Ohne Argument sortiert latest() nach created_at. Für einen Blog musst du published_at übergeben.
Newsletter

Neue Tests, Tutorials und Projekte, per E-Mail.

Reproduzierbare Tests, versionierter Code, datierte Ergebnisse. Niemals Spam.