Tenké Controllery a neprůstřelná validace vstupů
Proč mají být controllery tenké (Thin Controllers), jak funguje Dependency Injection v metodách controlleru a jak bezpečně validovat uživatelský vstup.
Architektura tenkého Controlleru (Thin Controller)
Controller v čisté architektuře plní roli dirigenta:
- Přijme HTTP požadavek (
Request). - Provede striktní validaci vstupních dat.
- Předá řízení specializovaným doménovým třídám (Services, Actions).
- Zformátuje a vrátí HTTP odpověď (Blade pohled nebo JSON).
V moderním Laravelu se striktně vyhýbáme tzv. „tlustým controllerům“ (Fat Controllers). Controller nesmí přímo provádět složité výpočty, přímé zápisy na disk nebo nekonečné řetězce SQL dotazů.
1. Dependency Injection přímo do akcí Controlleru (Method Injection)
Všimni si našeho CourseController.php. Laravel disponuje mocným mechanismem Method Injection:
public function index(
Request $request,
StudentProgressService $progressService,
AnatomyInspectorService $inspectorService,
): View {
// Service Container automaticky vytvořil a předal obě služby!
}
Service Container frameworku pomocí reflexe zanalyzuje typové deklarace parametrů metody a automaticky vytvoří a předá potřebné instance služeb se všemi jejich závislostmi. Není potřeba psát manuální new ani volat app()->make().
2. Eager Loading pro prevenci N+1 dotazů v Controlleru
Častou chybou začátečníků je načítání relačních dat v cyklu:
// ŠPATNĚ: Způsobí N+1 dotazů do PostgreSQL!
$modules = CourseModule::all();
// Později v šabloně: $module->lessons (pro každý modul nový SQL SELECT!)
V CourseController::index() uplatňujeme striktní Eager Loading:
$modules = CourseModule::with(['lessons.quizQuestions'])
->orderBy('number')
->get();
Laravel provede přesně 3 optimalizované SQL dotazy (jeden pro moduly, jeden pro všechny lekce s WHERE course_module_id IN (...) a jeden pro kvízové otázky), čímž udrží dobu odezvy databáze na jednotkách milisekund bez ohledu na velikost kurzu.
3. Neprůstřelná validace uživatelských vstupů
Nikdy nevěřte vstupům z klientské strany! V metodě CourseController::switchProfile() provádíme explicitní validaci:
public function switchProfile(
Request $request,
StudentProgressService $progressService,
): RedirectResponse {
/** @var array{transfer_code: string} $validated */
$validated = $request->validate([
'transfer_code' => ['required', 'string', 'max:24'],
]);
$profile = $progressService->findByTransferCode($validated['transfer_code']);
if ($profile === null) {
return back()->with('error', 'Přenosový kód nebyl nalezen.');
}
return redirect()
->route('course.index')
->with('success', 'Postup studenta byl úspěšně obnoven!');
}
Co se stane při nevalidním vstupu:
- Pokud uživatel odešle prázdný kód nebo text delší než 24 znaků, metoda
$request->validate()okamžitě vyhodí výjimkuValidationException. - Framework tuto výjimku automaticky zachytí, přesměruje uživatele zpět na předchozí stránku, uloží chybové zprávy do session a zachová původní formulářová data (
old()). V Controlleru tak nemusíte psát žádnýif ($validator->fails())!
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\CourseLesson;
use App\Models\CourseModule;
use App\Services\StudentProgressService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
final class CourseController
{
/**
* Příklad tenké metody controlleru s Route Model Bindingem a Method Injection.
*/
public function module(
Request $request,
CourseModule $module,
StudentProgressService $progressService,
): View {
// Eager loading relací pro eliminaci N+1 dotazů
$module->load(['lessons.quizQuestions']);
$student = $progressService->resolveProfile(
$request->cookie(StudentProgressService::COOKIE_NAME)
);
return view('course.module', [
'module' => $module,
'student' => $student,
]);
}
/**
* Bezpečná akce s okamžitou validací vstupních dat.
*/
public function switchProfile(
Request $request,
StudentProgressService $progressService,
): RedirectResponse {
// Validace: Při selhání Laravel automaticky přesměruje zpět s chybou
$validated = $request->validate([
'transfer_code' => ['required', 'string', 'max:24'],
]);
$profile = $progressService->findByTransferCode($validated['transfer_code']);
if ($profile === null) {
return back()->with('error', 'Přenosový kód nebyl nalezen.');
}
return redirect()
->route('course.index')
->with('success', 'Postup byl úspěšně obnoven!')
->withCookie(cookie(
name: StudentProgressService::COOKIE_NAME,
value: $profile->token,
minutes: 525600,
));
}
}
🎯 Ověření znalostí z lekce
Kvíz otestuje pochopení architektury a reálného kódu probíraného v této lekci.
Co je hlavní zodpovědností „tenkého controlleru“ (Thin Controller) v Laravelu?
Jak v Laravelu funguje vstřikování závislostí do metod Controlleru (Method Injection)?
Co se stane při volání $request->validate([...]), pokud odeslaná data nesplňují zadaná pravidla?
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace App\Http\Controllers; |
| 6 | |
| 7 | use App\Enums\LifecyclePhase; |
| 8 | use App\Models\CourseLesson; |
| 9 | use App\Models\CourseModule; |
| 10 | use App\Models\StudentProfile; |
| 11 | use App\Services\AnatomyInspectorService; |
| 12 | use App\Services\StudentProgressService; |
| 13 | use Illuminate\Http\RedirectResponse; |
| 14 | use Illuminate\Http\Request; |
| 15 | use Illuminate\Support\Str; |
| 16 | use Illuminate\View\View; |
| 17 | |
| 18 | final class CourseController |
| 19 | { |
| 20 | /** |
| 21 | * Zobrazí hlavní rozcestník a přehled všech 10 modulů kurzu. |
| 22 | */ |
| 23 | public function index( |
| 24 | Request $request, |
| 25 | StudentProgressService $progressService, |
| 26 | AnatomyInspectorService $inspectorService, |
| 27 | ): View { |
| 28 | $modules = CourseModule::with(['lessons.quizQuestions']) |
| 29 | ->orderBy('number') |
| 30 | ->get(); |
| 31 | |
| 32 | $totalLessons = (int) $modules->sum(fn (CourseModule $m): int => $m->lessons->count()); |
| 33 | |
| 34 | $rawToken = $request->cookie(StudentProgressService::COOKIE_NAME); |
| 35 | $student = $request->attributes->get('studentProfile'); |
| 36 | |
| 37 | if (! ($student instanceof StudentProfile)) { |
| 38 | $student = $progressService->resolveProfile(is_string($rawToken) ? $rawToken : null); |
| 39 | } |
| 40 | |
| 41 | $stats = $progressService->getProgressStats($student, $totalLessons); |
| 42 | |
| 43 | return view('course.index', [ |
| 44 | 'modules' => $modules, |
| 45 | 'stats' => $stats, |
| 46 | 'student' => $student, |
| 47 | 'lifecyclePhases' => LifecyclePhase::cases(), |
| 48 | ]); |
| 49 | } |
| 50 | |
| 51 | /** |
| 52 | * Zobrazí přehled konkrétního modulu a seznam jeho lekcí. |
| 53 | */ |
| 54 | public function module( |
| 55 | Request $request, |
| 56 | CourseModule $module, |
| 57 | StudentProgressService $progressService, |
| 58 | ): View { |
| 59 | $module->load(['lessons.quizQuestions']); |
| 60 | |
| 61 | $rawToken = $request->cookie(StudentProgressService::COOKIE_NAME); |
| 62 | $student = $request->attributes->get('studentProfile'); |
| 63 | |
| 64 | if (! ($student instanceof StudentProfile)) { |
| 65 | $student = $progressService->resolveProfile(is_string($rawToken) ? $rawToken : null); |
| 66 | } |
| 67 | |
| 68 | return view('course.module', [ |
| 69 | 'module' => $module, |
| 70 | 'student' => $student, |
| 71 | ]); |
| 72 | } |
| 73 | |
| 74 | /** |
| 75 | * Zobrazí detail vybrané lekce, vyrenderovaný Markdown, soubory anatomie a navigaci. |
| 76 | */ |
| 77 | public function lesson( |
| 78 | Request $request, |
| 79 | CourseModule $module, |
| 80 | CourseLesson $lesson, |
| 81 | AnatomyInspectorService $inspectorService, |
| 82 | StudentProgressService $progressService, |
| 83 | ): View { |
| 84 | abort_if($lesson->course_module_id !== $module->id, 404); |
| 85 | |
| 86 | $lesson->load(['quizQuestions', 'module.lessons']); |
| 87 | |
| 88 | $renderedHtml = Str::markdown($lesson->content_markdown ?? ''); |
| 89 | |
| 90 | $anatomyNodes = $inspectorService->inspectMany($lesson->anatomy_files ?? []); |
| 91 | |
| 92 | $orderedLessons = CourseLesson::query() |
| 93 | ->select('course_lessons.*') |
| 94 | ->join('course_modules', 'course_lessons.course_module_id', '=', 'course_modules.id') |
| 95 | ->with('module') |
| 96 | ->orderBy('course_modules.number') |
| 97 | ->orderBy('course_lessons.number') |
| 98 | ->get(); |
| 99 | |
| 100 | $currentIndex = $orderedLessons->search(fn (CourseLesson $item): bool => $item->id === $lesson->id); |
| 101 | $previousLesson = $currentIndex !== false && $currentIndex > 0 ? $orderedLessons->get($currentIndex - 1) : null; |
| 102 | $nextLesson = $currentIndex !== false && $currentIndex < $orderedLessons->count() - 1 ? $orderedLessons->get($currentIndex + 1) : null; |
| 103 | |
| 104 | $rawToken = $request->cookie(StudentProgressService::COOKIE_NAME); |
| 105 | $student = $request->attributes->get('studentProfile'); |
| 106 | |
| 107 | if (! ($student instanceof StudentProfile)) { |
| 108 | $student = $progressService->resolveProfile(is_string($rawToken) ? $rawToken : null); |
| 109 | } |
| 110 | |
| 111 | return view('course.lesson', compact( |
| 112 | 'module', |
| 113 | 'lesson', |
| 114 | 'renderedHtml', |
| 115 | 'anatomyNodes', |
| 116 | 'previousLesson', |
| 117 | 'nextLesson', |
| 118 | 'student', |
| 119 | )); |
| 120 | } |
| 121 | |
| 122 | /** |
| 123 | * Přepne identitu studenta na jiném zařízení pomocí přenosového kódu. |
| 124 | */ |
| 125 | public function switchProfile( |
| 126 | Request $request, |
| 127 | StudentProgressService $progressService, |
| 128 | ): RedirectResponse { |
| 129 | /** @var array{transfer_code: string} $validated */ |
| 130 | $validated = $request->validate([ |
| 131 | 'transfer_code' => ['required', 'string', 'max:24'], |
| 132 | ]); |
| 133 | |
| 134 | $profile = $progressService->findByTransferCode($validated['transfer_code']); |
| 135 | |
| 136 | if ($profile === null) { |
| 137 | return back()->with('error', 'Přenosový kód nebyl nalezen. Zkontroluj prosím jeho tvar.'); |
| 138 | } |
| 139 | |
| 140 | return redirect() |
| 141 | ->route('course.index') |
| 142 | ->with('success', 'Postup studenta byl úspěšně obnoven!') |
| 143 | ->withCookie(cookie( |
| 144 | name: StudentProgressService::COOKIE_NAME, |
| 145 | value: $profile->token, |
| 146 | minutes: 525600, |
| 147 | )); |
| 148 | } |
| 149 | } |
| 150 | |