Fáze požadavku: 6. Controller & Action
Modul 7 · Lekce 1

Past tlustých Controllerů a Modelů: Rozdělení zodpovědností (SRP)

💡 Shrnutí lekce

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í:

  1. HTTP Controller: Zodpovídá výhradně za HTTP protokol. Přijme HTTP požadavek (Request), provede základní validaci vstupů (ideálně přes FormRequest), zavolá příslušnou doménovou službu či Action třídu a transformuje výsledek do HTTP odpovědi (View, JsonResponse, RedirectResponse).
  2. 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).
  3. Eloquent Model: Reprezentuje perzistentní data, relační vazby mezi tabulkami a nízkoúrovňové datové transformace (casts, mutátory).
  4. 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

  1. Snadné jednotkové testy (Unit Testing): Servisní třídu StudentProgressService lze testovat izolovaně v Pest PHP bez nutnosti simulovat celý HTTP request/response cyklus.
  2. Znovupoužitelnost: Stejnou servisní metodu můžeme zavolat z konzolového příkazu php artisan student:sync, z Livewire komponenty i z webhooku.
  3. 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.
💻 Klíčová konstrukce z praxe
// 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.

Otázka 1 z 3

Co je hlavní architektonickou nevýhodou přístupu "Fat Controller" (tlustý kontroler)?

Otázka 2 z 3

Jaká je v čisté architektuře primární zodpovědnost HTTP Controlleru?

Otázka 3 z 3

Jakým způsobem CourseController v naší ukázce získává instanci StudentProgressService?

Zodpovězeno: 0 / 3
🔬 Živá anatomie v této aplikaci
2 soubory
Na co se v kódu zaměřit: Prohlédni si metodu index() nebo switchProfile() v app/Http/Controllers/CourseController.php. Všimni si, že controller neobsahuje žádné generování kódů, matematiku skóre ani přímou manipulaci s JSON poli — veškerou doménovou logiku deleguje do injektované služby StudentProgressService.
cesta: app/Http/Controllers/CourseController.php
149 řádků · 4.9 KB
Resource controller obsluhující přehled modulů, detail lekce, markdown render a přepínání profilu studenta.
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
cesta: app/Services/StudentProgressService.php
173 řádků · 5.2 KB
Doménová služba pro správu anonymních profilů studentů, postupu lekcemi, výsledků kvízů a přenosových kódů.
1 <?php
2
3 declare(strict_types=1);
4
5 namespace App\Services;
6
7 use App\Models\StudentProfile;
8 use Illuminate\Support\Str;
9
10 final class StudentProgressService
11 {
12 public const string COOKIE_NAME = 'agy_student_token';
13
14 /**
15 * Získá existující profil podle UUID tokenu, nebo vytvoří nový anonymní profil.
16 */
17 public function resolveProfile(?string $token): StudentProfile
18 {
19 if ($token !== null && Str::isUuid($token)) {
20 $existing = StudentProfile::where('token', $token)->first();
21 if ($existing instanceof StudentProfile) {
22 $existing->update(['last_active_at' => now()]);
23
24 return $existing;
25 }
26 }
27
28 return StudentProfile::create([
29 'token' => (string) Str::uuid(),
30 'transfer_code' => $this->generateUniqueTransferCode(),
31 'completed_lessons' => [],
32 'quiz_scores' => [],
33 'last_active_at' => now(),
34 ]);
35 }
36
37 /**
38 * Označí lekci jako dokončenou.
39 */
40 public function markLessonCompleted(StudentProfile $profile, int $lessonId): StudentProfile
41 {
42 /** @var list<int> $completed */
43 $completed = array_map('intval', (array) ($profile->completed_lessons ?? []));
44
45 if (! in_array($lessonId, $completed, true)) {
46 $completed[] = $lessonId;
47 sort($completed);
48 $profile->completed_lessons = $completed;
49 $profile->last_active_at = now();
50 $profile->save();
51 }
52
53 return $profile;
54 }
55
56 /**
57 * Přepne stav dokončení lekce (zapnuto / vypnuto).
58 */
59 public function toggleLessonCompleted(StudentProfile $profile, int $lessonId): StudentProfile
60 {
61 /** @var list<int> $completed */
62 $completed = array_map('intval', (array) ($profile->completed_lessons ?? []));
63
64 $key = array_search($lessonId, $completed, true);
65 if ($key !== false) {
66 unset($completed[$key]);
67 $completed = array_values($completed);
68 } else {
69 $completed[] = $lessonId;
70 sort($completed);
71 }
72
73 $profile->completed_lessons = $completed;
74 $profile->last_active_at = now();
75 $profile->save();
76
77 return $profile;
78 }
79
80 /**
81 * Zaznamená procentuální úspěšnost kvízu k dané lekci.
82 * Při skóre >= 66 % lekci automaticky označí jako dokončenou.
83 */
84 public function recordQuizScore(StudentProfile $profile, int $lessonId, int $scorePercent): StudentProfile
85 {
86 /** @var array<string, int> $scores */
87 $scores = (array) ($profile->quiz_scores ?? []);
88 $key = (string) $lessonId;
89
90 $scores[$key] = max((int) ($scores[$key] ?? 0), $scorePercent);
91 $profile->quiz_scores = $scores;
92
93 /** @var list<int> $completed */
94 $completed = array_map('intval', (array) ($profile->completed_lessons ?? []));
95
96 if ($scorePercent >= 66 && ! in_array($lessonId, $completed, true)) {
97 $completed[] = $lessonId;
98 sort($completed);
99 $profile->completed_lessons = $completed;
100 }
101
102 $profile->last_active_at = now();
103 $profile->save();
104
105 return $profile;
106 }
107
108 /**
109 * Vyhledá profil podle přenosového kódu (normalizovaného na velká písmena bez mezer).
110 */
111 public function findByTransferCode(string $code): ?StudentProfile
112 {
113 $normalized = strtoupper(trim($code));
114
115 if ($normalized === '') {
116 return null;
117 }
118
119 return StudentProfile::where('transfer_code', $normalized)->first();
120 }
121
122 /**
123 * Spočítá souhrnné statistiky postupu studenta.
124 *
125 * @return array{
126 * completedCount: int,
127 * totalCount: int,
128 * percentage: int,
129 * averageQuizScore: int,
130 * quizCount: int
131 * }
132 */
133 public function getProgressStats(StudentProfile $profile, int $totalLessons): array
134 {
135 /** @var list<int> $completed */
136 $completed = (array) ($profile->completed_lessons ?? []);
137 $completedCount = count($completed);
138
139 /** @var array<string, int|string> $scores */
140 $scores = (array) ($profile->quiz_scores ?? []);
141 $numericScores = array_map('intval', array_values($scores));
142 $quizCount = count($numericScores);
143 $averageQuizScore = $quizCount > 0
144 ? (int) round(array_sum($numericScores) / $quizCount)
145 : 0;
146
147 $percentage = $totalLessons > 0
148 ? (int) round(($completedCount / $totalLessons) * 100)
149 : 0;
150
151 return [
152 'completedCount' => $completedCount,
153 'totalCount' => $totalLessons,
154 'percentage' => min(100, $percentage),
155 'averageQuizScore' => $averageQuizScore,
156 'quizCount' => $quizCount,
157 ];
158 }
159
160 /**
161 * Vygeneruje unikátní, snadno čitelný přenosový kód tvaru AGY-XXXX-XXXX.
162 */
163 public function generateUniqueTransferCode(): string
164 {
165 do {
166 $chunk1 = strtoupper(Str::random(4));
167 $chunk2 = strtoupper(Str::random(4));
168 $code = "AGY-{$chunk1}-{$chunk2}";
169 } while (StudentProfile::where('transfer_code', $code)->exists());
170
171 return $code;
172 }
173 }
174
Zdroj: skutečné soubory této aplikace Plný průzkumník anatomie →