Konzolové příkazy Artisan: Tvorba příkazu pro audit a synchronizaci kurzu
Naučte se psát robustní konzolové příkazy v Laravelu 12 s injektovanými službami a přepínači. Prozkoumáme reálný příkaz VerifyCurriculumCommand, který hlídá naši aplikaci před halucinacemi.
Konzolové rozhraní Artisan v moderním Laravelu 12
Artisan je konzolové CLI rozhraní dodávané s Laravelem, postavené na robustní komponentě Symfony Console. V Laravelu 12 neslouží konzolové příkazy pouze k údržbě frameworku (jako migrate nebo cache:clear), ale představují primární nástroj pro automatizaci doménových procesů, synchronizaci dat, audit integrity a spouštění dávkových úloh.
Při návrhu profesionálních aplikací v PHP 8.4 píšeme příkazy jako tenké fasády nad doménovými službami. Samotný příkaz se stará o:
- Zpracování argumentů a přepínačů z příkazové řádky.
- Zobrazení formátovaného výstupu (tabulky, progress bary, barevné hlášky).
- Delegování byznys logiky do doménové vrstvy.
- Vrácení správného návratového kódu operačnímu systému (
self::SUCCESSneboself::FAILURE).
Anatomie podpisu příkazu ($signature)
Vlastnost $signature definuje název příkazu i jeho vstupní rozhraní. V našem projektu používáme příkaz curriculum:verify:
protected $signature = 'curriculum:verify {--db : Ověřit také přítomnost modulů a lekcí v databázi}';
curriculum:verify: Název příkazu s logickým jmenným prostorem (namespace oddělený dvojtečkou).{--db}: Volitelný booleovský přepínač (flag/option). Pokud uživatel spustíphp artisan curriculum:verify --db, metoda$this->option('db')vrátítrue. Pokud přepínač nezadá, vrátífalse.: Ověřit také přítomnost...: Popis přepínače, který se automaticky zobrazí v nápověděphp artisan curriculum:verify --help.
Automatická injekce závislostí v metodě handle()
Jednou z nejelegantnějších vlastností Laravel kontejneru je Method Injection v metodě handle(). Nemusíte složitě konfigurovat konstruktor příkazu – stačí otipovat parametr v metodě handle() a Laravel IoC kontejner jej při spuštění automaticky vyřeší a dosadí:
public function handle(AnatomyInspectorService $inspectorService): int
{
// $inspectorService je plně inicializovaná doménová služba
$inspected = $inspectorService->inspectFile($cleanFile);
// ...
}
Tento přístup udržuje kód testovatelný a čistý. Při testování příkazu můžete službu AnatomyInspectorService snadno nahradit mockem nebo testovacím dvojčetem.
Zero-Hallucination Audit v praxi naší platformy
V naší výukové platformě plní VerifyCurriculumCommand klíčovou roli v ochraně proti halucinacím. Příkaz načte soubor .agents/memory/curriculum_registry.json, který je jediným zdrojem pravdy (Single Source of Truth) pro osnovu kurzu, a pro každou lekci zkontroluje:
- Zda je definován platný a neprázdný
slug. - Zda existují všechny soubory uvedené v poli
anatomy_files. - Zda žádný soubor neporušuje bezpečnostní politiku (např. pokus o zobrazení
.envnebo souborů mimo repozitář). - Pokud je zadán parametr
--db, ověří, že každý modul i lekce jsou zaindexovány v PostgreSQL a mají přiřazené kvízové otázky.
Pokud příkaz narazí na jedinou nesrovnalost, vrátí self::FAILURE (kód 1). Tento exit status okamžitě zastaví CI/CD pipeline nebo noční kontrolu a zabrání nasazení nekonzistentního obsahu.
// app/Console/Commands/VerifyCurriculumCommand.php
namespace App\Console\Commands;
use App\Models\CourseLesson;
use App\Models\CourseModule;
use App\Services\AnatomyInspectorService;
use Illuminate\Console\Command;
final class VerifyCurriculumCommand extends Command
{
// Konzolový podpis příkazu včetně volitelného přepínače --db
protected $signature = 'curriculum:verify {--db : Ověřit také přítomnost modulů a lekcí v databázi}';
protected $description = 'Ověří integritu osnovy kurzu v .agents/memory/curriculum_registry.json a existenci všech odkazovaných anatomy_files';
// Laravel Service Container automaticky injektuje AnatomyInspectorService
public function handle(AnatomyInspectorService $inspectorService): int
{
$this->info('🔍 Spouštím Zero-Hallucination audit osnovy a souborů anatomie...');
$registryPath = base_path('.agents/memory/curriculum_registry.json');
if (! file_exists($registryPath)) {
$this->error("Chyba: Soubor osnovy '{$registryPath}' neexistuje!");
return self::FAILURE;
}
// Kontrola existence všech anatomy_files přes inspektor
// ...
return self::SUCCESS;
}
}
🎯 Ověření znalostí z lekce
Kvíz otestuje pochopení architektury a reálného kódu probíraného v této lekci.
Jak Laravel 12 předává závislosti (Dependency Injection) do metody handle() vlastního Artisan příkazu?
Co znamená definice {--db : Popis} ve vlastnosti $signature konzolového příkazu?
Proč je důležité, aby metoda handle() vracela návratový kód self::SUCCESS (0) nebo self::FAILURE (1)?
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace App\Console\Commands; |
| 6 | |
| 7 | use App\Models\CourseLesson; |
| 8 | use App\Models\CourseModule; |
| 9 | use App\Services\AnatomyInspectorService; |
| 10 | use Illuminate\Console\Command; |
| 11 | use Illuminate\Support\Facades\DB; |
| 12 | use Throwable; |
| 13 | |
| 14 | final class VerifyCurriculumCommand extends Command |
| 15 | { |
| 16 | /** |
| 17 | * Konzolový podpis příkazu. |
| 18 | * |
| 19 | * @var string |
| 20 | */ |
| 21 | protected $signature = 'curriculum:verify {--db : Ověřit také přítomnost modulů a lekcí v databázi}'; |
| 22 | |
| 23 | /** |
| 24 | * Popis příkazu. |
| 25 | * |
| 26 | * @var string |
| 27 | */ |
| 28 | protected $description = 'Ověří integritu osnovy kurzu v .agents/memory/curriculum_registry.json a existenci všech odkazovaných anatomy_files'; |
| 29 | |
| 30 | public function handle(AnatomyInspectorService $inspectorService): int |
| 31 | { |
| 32 | $this->info('🔍 Spouštím Zero-Hallucination audit osnovy a souborů anatomie...'); |
| 33 | |
| 34 | $registryPath = base_path('.agents/memory/curriculum_registry.json'); |
| 35 | |
| 36 | if (! file_exists($registryPath)) { |
| 37 | $this->error("Chyba: Soubor osnovy '{$registryPath}' neexistuje!"); |
| 38 | |
| 39 | return self::FAILURE; |
| 40 | } |
| 41 | |
| 42 | /** @var array{course_title?: string, version?: string, modules?: array<int, array<string, mixed>>}|null $registry */ |
| 43 | $registry = json_decode((string) file_get_contents($registryPath), true); |
| 44 | |
| 45 | if (! is_array($registry) || ! isset($registry['modules']) || ! is_array($registry['modules'])) { |
| 46 | $this->error("Chyba: Soubor osnovy '{$registryPath}' neobsahuje platný JSON s polem 'modules'."); |
| 47 | |
| 48 | return self::FAILURE; |
| 49 | } |
| 50 | |
| 51 | $courseTitle = (string) ($registry['course_title'] ?? 'Neznámý kurz'); |
| 52 | $this->line("Kurz: <fg=cyan>{$courseTitle}</> (verze: ".($registry['version'] ?? '1.0.0').')'); |
| 53 | |
| 54 | $errors = []; |
| 55 | $totalModules = count($registry['modules']); |
| 56 | $totalLessons = 0; |
| 57 | $checkedFiles = []; |
| 58 | |
| 59 | foreach ($registry['modules'] as $module) { |
| 60 | $moduleNumber = $module['number'] ?? '?'; |
| 61 | $moduleSlug = (string) ($module['slug'] ?? ''); |
| 62 | $lessons = (array) ($module['lessons'] ?? []); |
| 63 | |
| 64 | if (empty($moduleSlug)) { |
| 65 | $errors[] = "Modul #{$moduleNumber} nemá definován platný 'slug'."; |
| 66 | } |
| 67 | |
| 68 | foreach ($lessons as $lesson) { |
| 69 | $totalLessons++; |
| 70 | $lessonSlug = (string) ($lesson['slug'] ?? 'neznama-lekce'); |
| 71 | $anatomyFiles = (array) ($lesson['anatomy_files'] ?? []); |
| 72 | |
| 73 | if (empty($anatomyFiles)) { |
| 74 | $errors[] = "Modul [{$moduleSlug}] lekce [{$lessonSlug}]: Pole 'anatomy_files' je prázdné!"; |
| 75 | } |
| 76 | |
| 77 | foreach ($anatomyFiles as $filePath) { |
| 78 | $cleanFile = ltrim((string) $filePath, '/'); |
| 79 | $checkedFiles[$cleanFile] = true; |
| 80 | |
| 81 | $inspected = $inspectorService->inspectFile($cleanFile); |
| 82 | if (! $inspected->exists) { |
| 83 | $errors[] = "Modul [{$moduleSlug}] lekce [{$lessonSlug}]: Odkazovaný anatomy_file '{$cleanFile}' neexistuje nebo je blokován bezpečnostní politikou!"; |
| 84 | } |
| 85 | } |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | $uniqueFilesCount = count($checkedFiles); |
| 90 | $this->line("Auditováno <info>{$totalModules}</info> modulů, <info>{$totalLessons}</info> lekcí a <info>{$uniqueFilesCount}</info> unikátních souborů anatomie."); |
| 91 | |
| 92 | $checkDb = (bool) $this->option('db'); |
| 93 | if ($checkDb) { |
| 94 | $this->info('🗄️ Ověřuji integritu záznamů v databázi PostgreSQL...'); |
| 95 | |
| 96 | try { |
| 97 | DB::connection()->getPdo(); |
| 98 | } catch (Throwable $e) { |
| 99 | $this->error('Připojení k databázi selhalo: '.$e->getMessage()); |
| 100 | |
| 101 | return self::FAILURE; |
| 102 | } |
| 103 | |
| 104 | foreach ($registry['modules'] as $module) { |
| 105 | $moduleSlug = (string) ($module['slug'] ?? ''); |
| 106 | $dbModule = CourseModule::where('slug', $moduleSlug)->first(); |
| 107 | |
| 108 | if (! $dbModule) { |
| 109 | $errors[] = "Databáze: Modul se slugem '{$moduleSlug}' nebyl nalezen v tabulce course_modules!"; |
| 110 | |
| 111 | continue; |
| 112 | } |
| 113 | |
| 114 | $lessons = (array) ($module['lessons'] ?? []); |
| 115 | foreach ($lessons as $lesson) { |
| 116 | $lessonSlug = (string) ($lesson['slug'] ?? ''); |
| 117 | $dbLesson = CourseLesson::where('course_module_id', $dbModule->id) |
| 118 | ->where('slug', $lessonSlug) |
| 119 | ->first(); |
| 120 | |
| 121 | if (! $dbLesson) { |
| 122 | $errors[] = "Databáze: Lekce se slugem '{$lessonSlug}' v modulu '{$moduleSlug}' nebyla nalezena v tabulce course_lessons!"; |
| 123 | |
| 124 | continue; |
| 125 | } |
| 126 | |
| 127 | $quizCount = $dbLesson->quizQuestions()->count(); |
| 128 | if ($quizCount === 0) { |
| 129 | $this->warn("Upozornění: Lekce '{$lessonSlug}' modulu '{$moduleSlug}' nemá v DB žádné kvízové otázky."); |
| 130 | } |
| 131 | } |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | if (! empty($errors)) { |
| 136 | $this->newLine(); |
| 137 | $this->error('❌ Zjištěno '.count($errors).' chyb integrity osnovy:'); |
| 138 | foreach ($errors as $err) { |
| 139 | $this->line(" <fg=red>•</> {$err}"); |
| 140 | } |
| 141 | |
| 142 | return self::FAILURE; |
| 143 | } |
| 144 | |
| 145 | $this->newLine(); |
| 146 | $this->info('✅ Všechny moduly, lekce i anatomy_files v curriculum_registry.json jsou 100% platné a bez halucinací!'); |
| 147 | |
| 148 | return self::SUCCESS; |
| 149 | } |
| 150 | } |
| 151 | |