Past tlustých Controllerů a Modelů: Rozdělení zodpovědností (SRP)
Proč "Fat Controller" a "Fat Model" vedou k technickému dluhu a jak v Laravelu aplikovat Single Responsibility Principle rozdělením zodpovědností mezi HTTP vrstvu, služby a Eloquent.
Problém "Fat Controller" a "Fat Model" v Laravelu
V raných fázích vývoje Laravel aplikací je lákavé vkládat veškerou aplikační logiku přímo do metod Controlleru. V jediném kontroleru pak často najdete validaci, autorizaci, orchestraci externích API, manipulaci s databázovými transakcemi, odesílání e-mailů a formátování odpovědi. Takový přístup se nazývá Fat Controller (tlustý kontroler).
Opačným extrémem bývá přesun veškeré logiky do Eloquent modelů – tzv. Fat Model. Modely pak obsahují desítky metod, které nesouvisejí s reprezentací datové entity, ale s orchestrací byznys procesů, generováním reportů nebo integrací třetích stran.
Oba tyto přístupy porušují první ze SOLID principů: Single Responsibility Principle (SRP) — třída by měla mít pouze jeden jediný důvod ke změně.
Jak vypadá rozdělení zodpovědností (Separation of Concerns)?
V moderním Laravelu 12 uplatňujeme čisté vrstvení:
- HTTP Controller: Zodpovídá výhradně za HTTP protokol. Přijme HTTP požadavek (
Request), provede základní validaci vstupů (ideálně přesFormRequest), zavolá příslušnou doménovou službu či Action třídu a transformuje výsledek do HTTP odpovědi (View,JsonResponse,RedirectResponse). - Service / Action vrstva: Zapouzdřuje doménovou logiku aplikace. Nezávisí na HTTP protokolu (může být volána z Controlleru, Artisan příkazu, fronty i testu).
- Eloquent Model: Reprezentuje perzistentní data, relační vazby mezi tabulkami a nízkoúrovňové datové transformace (
casts, mutátory). - Data Transfer Objects (DTO): Zajišťují bezpečný, typovaný a neměnný přenos dat mezi jednotlivými vrstvami.
Příklad z našeho projektu: Tenký CourseController
V našem vzdělávacím projektu agy-laravel důsledně uplatňujeme tenké kontrolery. Podívejme se na metodu switchProfile v CourseController.php:
public function switchProfile(
Request $request,
StudentProgressService $progressService,
): RedirectResponse {
/** @var array{transfer_code: string} $validated */
$validated = $request->validate([
'transfer_code' => ['required', 'string', 'max:24'],
]);
// Doménová logika vyhledání a validace kódu je delegována do služby
$profile = $progressService->findByTransferCode($validated['transfer_code']);
if ($profile === null) {
return back()->with('error', 'Přenosový kód nebyl nalezen. Zkontroluj prosím jeho tvar.');
}
return redirect()
->route('course.index')
->with('success', 'Postup studenta byl úspěšně obnoven!')
->withCookie(cookie(
name: StudentProgressService::COOKIE_NAME,
value: $profile->token,
minutes: 525600,
));
}
Všimněte si:
- Controller se nestará o to, jak se normalizuje kód studenta (odstranění mezer, velká písmena).
- Controller se nestará o to, ve kterém sloupci či tabulce je kód uložen.
- Metoda pouze převezme vstup, zavolá
$progressService->findByTransferCode(...)a podle výsledku vrátí buď redirect s chybovou hláškou, nebo redirect s úspěchem a nastavením cookie.
Výhody pro testování a údržbu
- Snadné jednotkové testy (Unit Testing): Servisní třídu
StudentProgressServicelze testovat izolovaně v Pest PHP bez nutnosti simulovat celý HTTP request/response cyklus. - Znovupoužitelnost: Stejnou servisní metodu můžeme zavolat z konzolového příkazu
php artisan student:sync, z Livewire komponenty i z webhooku. - Přehlednost a čitelnost: Metody kontroleru mají 10–20 řádků a na první pohled je zřejmé, co je vstupem a jaký je výsledek.
// app/Http/Controllers/CourseController.php:125-148
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. Zkontroluj prosím jeho tvar.');
}
return redirect()
->route('course.index')
->with('success', 'Postup studenta 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í architektonickou nevýhodou přístupu "Fat Controller" (tlustý kontroler)?
Jaká je v čisté architektuře primární zodpovědnost HTTP Controlleru?
Jakým způsobem CourseController v naší ukázce získává instanci StudentProgressService?
| 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 | |