CLI FEATURE
Scaffolding Generators
The generator surface matters because it teaches the framework shape by producing the right files, not by asking the developer to remember ceremony.
Feature Guide
A quick orientation block that answers the essential questions: what this feature does, how it works, why it matters, and the key concepts behind it.
What this does
Semitexa generators scaffold framework-native files and can also emit machine-readable planning hints for AI-assisted implementation.
How it works
Commands like make:module, make:page, make:payload, make:service, and make:contract use builders and template resolvers to produce correctly placed files, with dry-run, JSON, and llm-hints modes where appropriate.
Why it matters
Good scaffolding is not just about speed. It teaches the expected architecture by generating the right boundaries and naming conventions from the start.
Key concepts
- make:page
- Scaffolds a complete page boundary: payload, handler, resource, and template.
- --llm-hints
- Outputs a machine-readable envelope describing what files were created and what should be implemented next.
- dry-run
- Lets the user inspect the generation plan before any files are written.
Code Generation
Scaffolding that already knows Semitexa conventions
These commands do not just dump stubs. They encode module structure, naming, response boundaries, and even AI-oriented follow-up hints, so the generated result starts aligned with the framework.
Structure-aware. make:module and make:page know the expected module, payload, handler, resource, and template layout without asking the developer to assemble it manually.
Machine-readable when needed. Dry-run, JSON, and --llm-hints modes let both humans and agents inspect the plan before committing files.
Teaches the shape. Good scaffolding shortens onboarding because the produced files demonstrate the intended Semitexa architecture directly.
| Command | Purpose | Why it matters |
|---|---|---|
bin/semitexa make:module --name=Catalog |
Create a new module with standard directories already in place. | Removes the need to remember directory conventions or Composer changes. |
bin/semitexa make:page --module=Catalog --name=Pricing --path=/pricing --method=GET |
Scaffold a full SSR page boundary in one step. | Creates the payload, handler, resource, and template as a coherent unit. |
bin/semitexa make:payload --module=Catalog --name=CreateProduct --path=/products --method=POST --response=CreateProduct |
Generate only the transport boundary when you need a narrower step. | Useful when the payload contract should be reviewed before the rest of the implementation. |
bin/semitexa make:contract --module=Catalog --name=PriceFeed --implementation=ApiPriceFeed --llm-hints |
Scaffold a DI contract plus implementation and emit follow-up hints. | Great fit for AI-assisted workflows because the command can describe what to fill next. |
Preview a new page without writing files
bin/semitexa make:page --module=Catalog --name=Pricing --path=/pricing --method=GET --dry-run
Generate a payload with agent-friendly hints
bin/semitexa make:payload --module=Catalog --name=CreateProduct --path=/products --method=POST --response=CreateProduct --llm-hints
Scaffold a contract and verify it after generation
bin/semitexa make:contract --module=Catalog --name=PriceFeed --implementation=ApiPriceFeed
bin/semitexa contracts:list --json
Verified against Semitexa Ultimate 2026.09.19.1020
Scaffolding Generators
The generator surface matters because it teaches the framework shape by producing the right files, not by asking the developer to remember ceremony.
How it works
make:module, make:page, make:payload, make:service, make:contract, make:handler, make:resource, make:command, and make:event-listener each encode Semitexa's expected file layout and attribute conventions directly into the generated stubs. Dry-run and --llm-hints modes let humans and agents inspect the plan before files are written. JSON output makes generator results consumable by automated workflows.
Payload access modifiers
Both make:payload and make:page accept --access=public|protected|service. The flag picks exactly one of the three current access attributes:
--access value |
Generated attribute | When to use |
|---|---|---|
public (explicit) |
#[AsPublicPayload] |
Anonymous endpoints — login, marketing pages, health checks. |
protected (default) |
#[AsProtectedPayload] |
User-authenticated endpoints — the safe default. |
service |
#[AsServicePayload] |
Machine-to-machine endpoints — webhook receivers, partner integrations, internal service callers. |
The default is protected so an omitted flag still produces a closed-by-default endpoint. An invalid value (--access=open, --access=foo) fails the generator with a clear Unknown payload access type error before any file is written. The three access attributes are mutually exclusive in generated output — GeneratorForbiddenPatternRegressionTest::payload_plan_builder_emits_exactly_one_access_attribute pins the contract.
Why this matters
The point is not fewer keystrokes. The point is fewer incorrect architectural starts. Good scaffolding shortens onboarding because the produced files demonstrate the intended structure directly and reduce wrong patterns before they become habits. The framework's regression suite scans every template and every plan builder output for retired payload attributes (see the post-hardening migration guide for the full list), so a future template tweak that silently reintroduces a stale shape fails CI before it lands.
© Linus Torvalds: "Talk is cheap. Show me the code."
1<?php23declare(strict_types=1);45namespace Semitexa\Dev\Application\Console\Command;67use Semitexa\Core\Attribute\AsCommand;8use Semitexa\Core\Console\BaseCommand;9use Semitexa\Dev\Application\Service\Generation\Support\GenerationPreflight;10use Semitexa\Dev\Application\Service\Generation\Support\GenerationExitCode;11use Semitexa\Dev\Application\Service\Generation\Support\GenerationOutcomeRenderer;12use Semitexa\Dev\Application\Service\Generation\Builder\ModulePlanBuilder;13use Semitexa\Dev\Application\Service\Generation\Support\JsonResultFormatter;14use Semitexa\Dev\Application\Service\Generation\Support\LlmHintsFormatter;15use Semitexa\Dev\Application\Service\Generation\Support\NameInflector;16use Semitexa\Dev\Application\Service\Generation\Support\ReplayArgBuilder;17use Semitexa\Dev\Application\Service\Generation\Verifier\PostWriteLinter;18use Semitexa\Dev\Application\Service\Generation\Writer\SafeFileWriter;19use Symfony\Component\Console\Input\InputInterface;20use Symfony\Component\Console\Input\InputOption;21use Symfony\Component\Console\Output\OutputInterface;22use Symfony\Component\Console\Style\SymfonyStyle;2324#[AsCommand(name: 'make:module', description: 'Scaffold a new module with the standard directory structure')]25final class MakeModuleCommand extends BaseCommand26{27 /** @see MakePageCommand::REQUIRED_OPTIONS */28 public const REQUIRED_OPTIONS = ['name'];2930 private const TARGET_CUSTOM = ModulePlanBuilder::TARGET_CUSTOM;31 private const TARGET_PACKAGE = ModulePlanBuilder::TARGET_PACKAGE;3233 private ?NameInflector $inflector = null;3435 protected function configure(): void36 {37 $this38 ->addOption('name', null, InputOption::VALUE_REQUIRED, 'Module name (e.g., Catalog)')39 ->addOption('target', null, InputOption::VALUE_REQUIRED, 'Module target: custom (src/modules) or package (packages/semitexa-...)')40 ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show planned files without creating them (explicit)')41 ->addOption('write', null, InputOption::VALUE_NONE, 'Actually write files (dry-run is the default)')42 ->addOption('force', null, InputOption::VALUE_NONE, 'Overwrite existing files')43 ->addOption('json', null, InputOption::VALUE_NONE, 'Output as JSON')44 ->addOption('llm-hints', null, InputOption::VALUE_NONE, 'Output LLM hints envelope');45 }4647 protected function execute(InputInterface $input, OutputInterface $output): int48 {49 $io = new SymfonyStyle($input, $output);5051 $rejected = GenerationPreflight::check($input, $output, 'make:module', self::REQUIRED_OPTIONS, $this->getProjectRoot());52 if ($rejected !== null) {53 return $rejected;54 }5556 $this->inflector = new NameInflector();57 $builder = new ModulePlanBuilder($this->inflector);58 $target = $this->resolveTarget($input, $io);59 if ($target === null) {60 return GenerationPreflight::reject($input, $output, 'make:module', GenerationPreflight::REASON_INVALID_OPTION, 'Invalid --target. Allowed values: custom, package.');61 }62 $replayArgs = $this->buildReplayArgs($input, $target);63 $module = $this->inflector->toStudly($input->getOption('name'));6465 $plan = $builder->build([66 'name' => $input->getOption('name'),67 'target' => $target,68 'dryRun' => $input->getOption('dry-run') || !$input->getOption('write'),69 ]);7071 $plannedResult = new \Semitexa\Dev\Application\Service\Generation\Data\GenerationResult(72 command: 'make:module',73 status: 'dry_run',74 created: array_map(static fn($file): string => $file->path, $plan->files),75 next_steps: ['Re-run with --write to create files'],76 replay_args: $replayArgs,77 );7879 if ($plan->dryRun) {80 if ($input->getOption('json')) {81 $output->writeln((new JsonResultFormatter())->format($plannedResult));82 return self::SUCCESS;83 }8485 if ($input->getOption('llm-hints')) {86 $formatter = new LlmHintsFormatter();87 $output->writeln($formatter->format('module_scaffold', $plannedResult, [88 'facts' => $this->buildFacts($module, $target),89 'suggested_next_prompt' => $this->buildSuggestedNextPrompt($module, $target),90 ]));91 return self::SUCCESS;92 }9394 $io->title('Dry Run — Planned Files');95 foreach ($plan->files as $file) {96 $io->text($file->path);97 }98 return self::SUCCESS;99 }100101 $writer = new SafeFileWriter($this->getProjectRoot(), 'make:module');102 $result = $writer->write($plan->files, (bool) $input->getOption('force'));103 $result = (new PostWriteLinter($this->getApplication()))->lintAfterWrite($result);104 $result = $result->withReplayArgs($replayArgs);105106 if ($input->getOption('json')) {107 $output->writeln((new JsonResultFormatter())->format($result));108 return GenerationExitCode::forResult($result);109 }110111 if ($input->getOption('llm-hints')) {112 $formatter = new LlmHintsFormatter();113 $output->writeln($formatter->format('module_scaffold', $result, [114 'facts' => $this->buildFacts($module, $target),115 'suggested_next_prompt' => $this->buildSuggestedNextPrompt($module, $target),116 ]));117 return GenerationExitCode::forResult($result);118 }119120 if ($result->created) {121 $io->success(sprintf(122 'Module %s created as a %s module in %s.',123 $module,124 $target,125 $this->targetRootLabel($module, $target),126 ));127 $io->text($this->buildNextText($target));128 }129130 GenerationOutcomeRenderer::renderProblems($io, $result);131132 return GenerationExitCode::forResult($result);133 }134135 private function resolveTarget(InputInterface $input, SymfonyStyle $io): ?string136 {137 $target = $input->getOption('target');138 if (is_string($target)) {139 if ($target === '') {140 return null;141 }142143 if (in_array($target, [self::TARGET_CUSTOM, self::TARGET_PACKAGE], true)) {144 return $target;145 }146147 return null;148 }149150 if (!$input->isInteractive() || $input->getOption('json') || $input->getOption('llm-hints')) {151 return self::TARGET_CUSTOM;152 }153154 $io->section('Choose module target');155 $io->text('`custom`: create a project-specific module in `src/modules/{Module}/` with `src/` (runtime) and `tests/` siblings. Choose this when the code belongs only to the current app and does not need package metadata.');156 $io->text('`package`: create a reusable module package in `packages/semitexa-{module}` with its own `composer.json`. Choose this when the module should be versioned, released, or shared across projects.');157 $io->text('Package mode currently scaffolds the package shell only. Downstream generators like `make:page`, `make:service`, and `make:contract` still target `src/modules` until they become target-aware.');158 $io->text('Both options follow the same Semitexa module structure. The real difference is ownership and where the module lives.');159160 return $io->choice(161 'Which target should `make:module` scaffold?',162 [self::TARGET_CUSTOM, self::TARGET_PACKAGE],163 self::TARGET_CUSTOM,164 );165 }166167 /**168 * @return list<string>169 */170 private function buildReplayArgs(InputInterface $input, string $target): array171 {172 $args = ReplayArgBuilder::fromInput($input, ['name']);173 $args[] = '--target=' . $target;174175 return $args;176 }177178 /**179 * @return list<string>180 */181 private function buildFacts(string $module, string $target): array182 {183 if ($target === self::TARGET_PACKAGE) {184 $slug = $this->moduleSlug($module);185 return [186 sprintf('Composer package: semitexa/%s', $slug),187 sprintf('Source root: packages/semitexa-%s/src', $slug),188 sprintf('Module namespace: Semitexa\\%s', $module),189 'Use this when the module should be reusable, versioned, or shipped independently.',190 'Package mode scaffolds the module shell only; downstream generators still target src/modules until package-aware follow-up support lands.',191 ];192 }193194 return [195 sprintf('Module namespace: Semitexa\\Modules\\%s', $module),196 sprintf('Source root: src/modules/%s/src', $module),197 sprintf('Tests root: src/modules/%s/tests', $module),198 'All directories follow the standard convention and are auto-discovered.',199 'Use this when the module is app-specific and does not need its own package lifecycle.',200 ];201 }202203 private function targetRootLabel(string $module, string $target): string204 {205 if ($target === self::TARGET_PACKAGE) {206 return sprintf('packages/semitexa-%s', $this->moduleSlug($module));207 }208209 return sprintf('src/modules/%s', $module);210 }211212 private function moduleSlug(string $module): string213 {214 return ($this->inflector ?? new NameInflector())->toKebab($module);215 }216217 private function buildSuggestedNextPrompt(string $module, string $target): string218 {219 if ($target === self::TARGET_PACKAGE) {220 return "Review the package scaffold for {$module} and add package-specific code manually until the downstream generators become target-aware.";221 }222223 return "Use make:page, make:service, or make:contract to add code to the {$module} module.";224 }225226 private function buildNextText(string $target): string227 {228 if ($target === self::TARGET_PACKAGE) {229 return 'Next: package mode currently scaffolds the shell only; add files manually until the downstream generators support package targets.';230 }231232 return 'Next: use make:page, make:service, or make:contract to add code.';233 }234}235
1<?php23declare(strict_types=1);45namespace Semitexa\Dev\Application\Console\Command;67use Semitexa\Core\Attribute\AsCommand;8use Semitexa\Core\Attribute\InjectAsReadonly;9use Semitexa\Core\Console\BaseCommand;10use Semitexa\Core\Discovery\ClassDiscovery;11use Semitexa\Dev\Application\Service\Capability\FrameworkCapabilityCatalog;12use Semitexa\Dev\Application\Service\Generation\Support\GenerationExitCode;13use Semitexa\Dev\Application\Service\Generation\Support\GenerationPreflight;14use Semitexa\Dev\Application\Service\Generation\Support\GenerationOutcomeRenderer;15use Semitexa\Dev\Application\Service\Generation\Builder\PagePlanBuilder;16use Semitexa\Dev\Application\Service\Generation\Support\JsonResultFormatter;17use Semitexa\Dev\Application\Service\Generation\Support\LlmHintsFormatter;18use Semitexa\Dev\Application\Service\Generation\Support\NameInflector;19use Semitexa\Dev\Application\Service\Generation\Support\ReplayArgBuilder;20use Semitexa\Dev\Application\Service\Generation\Support\TemplateRenderer;21use Semitexa\Dev\Application\Service\Generation\Support\TemplateResolver;22use Semitexa\Dev\Application\Service\Generation\Verifier\PostWriteLinter;23use Semitexa\Dev\Application\Service\Generation\Writer\SafeFileWriter;24use Symfony\Component\Console\Input\InputInterface;25use Symfony\Component\Console\Input\InputOption;26use Symfony\Component\Console\Output\OutputInterface;27use Symfony\Component\Console\Style\SymfonyStyle;2829#[AsCommand(name: 'make:page', description: 'Scaffold a complete page: Payload + Handler + Resource + template')]30final class MakePageCommand extends BaseCommand31{32 /**33 * Options execute() refuses to run without. Symfony's VALUE_REQUIRED only34 * requires a value when the option is given; presence is checked by35 * {@see GenerationPreflight}, and NextCommandContractTest reads this list.36 */37 public const REQUIRED_OPTIONS = ['module', 'name', 'path', 'method'];3839 #[InjectAsReadonly]40 protected ClassDiscovery $classDiscovery;4142 protected function configure(): void43 {44 $this45 ->addOption('module', null, InputOption::VALUE_REQUIRED, 'Target module name')46 ->addOption('name', null, InputOption::VALUE_REQUIRED, 'Page name')47 ->addOption('path', null, InputOption::VALUE_REQUIRED, 'Route path')48 ->addOption('method', null, InputOption::VALUE_REQUIRED, 'HTTP method')49 ->addOption('layout', null, InputOption::VALUE_REQUIRED, 'Layout template name')50 ->addOption('access', null, InputOption::VALUE_REQUIRED, 'Payload access type: public | protected | service', 'protected')51 ->addOption('with-assets', null, InputOption::VALUE_NONE, 'Generate CSS/JS/assets.json stubs')52 ->addOption('no-test', null, InputOption::VALUE_NONE, 'Skip the payload + handler test scaffolds (generated by default)')53 ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show planned files without writing (explicit)')54 ->addOption('write', null, InputOption::VALUE_NONE, 'Actually create files (dry-run is the default)')55 ->addOption('force', null, InputOption::VALUE_NONE, 'Overwrite existing files')56 ->addOption('json', null, InputOption::VALUE_NONE, 'Output as JSON')57 ->addOption('llm-hints', null, InputOption::VALUE_NONE, 'Output LLM hints envelope');58 }5960 protected function execute(InputInterface $input, OutputInterface $output): int61 {62 $io = new SymfonyStyle($input, $output);6364 $rejected = GenerationPreflight::check($input, $output, 'make:page', self::REQUIRED_OPTIONS, $this->getProjectRoot());65 if ($rejected !== null) {66 return $rejected;67 }6869 $inflector = new NameInflector();70 $resolver = new TemplateResolver();71 $renderer = new TemplateRenderer();72 $builder = new PagePlanBuilder($inflector, $resolver, $renderer);73 $replayArgs = ReplayArgBuilder::fromInput($input, ['module', 'name', 'path', 'method', 'layout', 'access'], ['with-assets', 'no-test']);7475 $plan = $builder->build([76 'module' => $input->getOption('module'),77 'name' => $input->getOption('name'),78 'path' => $input->getOption('path'),79 'method' => $input->getOption('method'),80 'layout' => $input->getOption('layout'),81 'access' => (string) $input->getOption('access'),82 'withAssets' => (bool) $input->getOption('with-assets'),83 'withTest' => !$input->getOption('no-test'),84 'dryRun' => $input->getOption('dry-run') || !$input->getOption('write'),85 ]);8687 $plannedResult = new \Semitexa\Dev\Application\Service\Generation\Data\GenerationResult(88 command: 'make:page',89 status: 'dry_run',90 created: array_map(static fn($file): string => $file->path, $plan->files),91 next_steps: ['Re-run with --write to create files'],92 replay_args: $replayArgs,93 );9495 if ($plan->dryRun) {96 if ($input->getOption('json')) {97 $output->writeln((new JsonResultFormatter())->format($plannedResult));98 return self::SUCCESS;99 }100101 if ($input->getOption('llm-hints')) {102 $module = $inflector->toStudly($input->getOption('module'));103 $name = $inflector->toStudly($input->getOption('name'));104 $kebab = $inflector->toKebab($name);105 $formatter = new LlmHintsFormatter();106 $output->writeln($formatter->format('page_scaffold', $plannedResult, [107 'fill_targets' => [108 "src/modules/{$module}/src/Application/Payload/Request/{$inflector->toPayloadClass($name)}.php" => [109 'Add properties for request parameters',110 'Throw Semitexa\\Core\\Exception\\ValidationException from setters to reject invalid input',111 ],112 "src/modules/{$module}/src/Application/Handler/PayloadHandler/{$inflector->toHandlerClass($name)}.php" => [113 'Implement business logic in handle()',114 'Populate resource via fluent setters',115 ],116 "src/modules/{$module}/src/Application/Resource/Response/{$inflector->toResponseClass($name)}.php" => [117 'Add fluent with*() setter methods',118 ],119 "src/modules/{$module}/src/Application/View/templates/pages/{$kebab}.html.twig" => [120 'Build the page HTML template',121 ],122 ],123 'facts' => [124 'All three classes are auto-discovered via PHP attributes',125 'The handler receives a hydrated payload and empty resource',126 'Template variables are set via Resource::with() method',127 ],128 'constraints' => [129 'Handler must be final class',130 'Resource must extend HtmlResponse and implement ResourceInterface',131 'Payload setters throw Semitexa\\Core\\Exception\\ValidationException to reject invalid input at hydration time',132 ],133 'mechanisms' => $this->mechanismHints(),134 'suggested_next_prompt' => "Open the handler at src/modules/{$module}/src/Application/Handler/PayloadHandler/{$inflector->toHandlerClass($name)}.php and implement the business logic.",135 ]));136 return self::SUCCESS;137 }138139 $io->title('Dry Run — Planned Files');140 foreach ($plan->files as $file) {141 $io->section($file->path);142 $output->writeln($file->content);143 }144145 // Dry run is the default mode, so this is the branch most callers146 // actually see — the nudge belongs here as much as after a write.147 $this->announceMechanisms($output);148 return self::SUCCESS;149 }150151 $writer = new SafeFileWriter($this->getProjectRoot(), 'make:page');152 $result = $writer->write($plan->files, (bool) $input->getOption('force'));153 $result = (new PostWriteLinter($this->getApplication()))->lintAfterWrite($result);154 $result = $result->withReplayArgs($replayArgs);155156 if ($input->getOption('json')) {157 $output->writeln((new JsonResultFormatter())->format($result));158 return GenerationExitCode::forResult($result);159 }160161 if ($input->getOption('llm-hints')) {162 $module = $inflector->toStudly($input->getOption('module'));163 $name = $inflector->toStudly($input->getOption('name'));164 $kebab = $inflector->toKebab($name);165 $formatter = new LlmHintsFormatter();166 $output->writeln($formatter->format('page_scaffold', $result, [167 'fill_targets' => [168 "src/modules/{$module}/src/Application/Payload/Request/{$inflector->toPayloadClass($name)}.php" => [169 'Add properties for request parameters',170 'Throw Semitexa\\Core\\Exception\\ValidationException from setters to reject invalid input',171 ],172 "src/modules/{$module}/src/Application/Handler/PayloadHandler/{$inflector->toHandlerClass($name)}.php" => [173 'Implement business logic in handle()',174 'Populate resource via fluent setters',175 ],176 "src/modules/{$module}/src/Application/Resource/Response/{$inflector->toResponseClass($name)}.php" => [177 'Add fluent with*() setter methods',178 ],179 "src/modules/{$module}/src/Application/View/templates/pages/{$kebab}.html.twig" => [180 'Build the page HTML template',181 ],182 ],183 'facts' => [184 'All three classes are auto-discovered via PHP attributes',185 'The handler receives a hydrated payload and empty resource',186 'Template variables are set via Resource::with() method',187 ],188 'constraints' => [189 'Handler must be final class',190 'Resource must extend HtmlResponse and implement ResourceInterface',191 'Payload setters throw Semitexa\\Core\\Exception\\ValidationException to reject invalid input at hydration time',192 ],193 'mechanisms' => $this->mechanismHints(),194 'suggested_next_prompt' => "Open the handler at src/modules/{$module}/src/Application/Handler/PayloadHandler/{$inflector->toHandlerClass($name)}.php and implement the business logic.",195 ]));196 return GenerationExitCode::forResult($result);197 }198199 if ($result->created) {200 $io->success('Created ' . count($result->created) . ' files:');201 foreach ($result->created as $path) {202 $io->text(" - {$path}");203 }204 }205 GenerationOutcomeRenderer::renderProblems($io, $result);206207 $this->announceMechanisms($output);208209 return GenerationExitCode::forResult($result);210 }211212 /**213 * The SSR mechanisms, or none when the catalog cannot be reached.214 *215 * Advice must never be the reason a generator fails. The collaborator is216 * filled by the container at boot, so a directly constructed command — every217 * scaffolding test does this — leaves the typed property uninitialised, and218 * reading it blindly turned "we could not suggest anything" into "make:page219 * is broken". Scaffolding is the job; the nudge is a bonus and degrades to220 * silence.221 *222 * @return list<array{id: string, summary: string, use_when: string, avoid_when: string,223 * replaces: list<string>, see_also: string, kind: string,224 * declared_by: string, declared_by_short: string, package: string}>225 */226 private function mechanismCatalog(): array227 {228 if (!isset($this->classDiscovery)) {229 return [];230 }231232 try {233 return (new FrameworkCapabilityCatalog($this->classDiscovery))->inArea('ssr');234 } catch (\Throwable) {235 return [];236 }237 }238239 /**240 * Say what the framework can already do for a page, right after scaffolding241 * one.242 *243 * This is the only one of the three capability surfaces that speaks BEFORE244 * the mistake: the catalog answers when asked, the lint objects afterwards.245 * Whatever a generator emits becomes the pattern copied for the rest of the246 * project, so a generator that mentions no mechanism teaches that there are247 * none.248 *249 * Derived from the installed packages rather than written here, so a250 * mechanism added later is offered without touching this command.251 */252 private function announceMechanisms(OutputInterface $output): void253 {254 $catalog = $this->mechanismCatalog();255 if ($catalog === []) {256 return;257 }258259 $output->writeln('');260 $output->writeln('<info>Before filling this in — the framework already does:</info>');261 foreach ($catalog as $capability) {262 $output->writeln(sprintf(263 ' <comment>%s</comment> #[%s] — %s',264 $capability['id'],265 $capability['declared_by_short'],266 $capability['use_when'],267 ));268 }269 $output->writeln(' <info>Details:</info> bin/semitexa ai:ask mechanisms --id=<id>');270 $output->writeln('');271 }272273 /**274 * Mechanism list for the LLM hints envelope.275 *276 * @return list<array{capability: string, attribute: string, use_when: string, avoid_when: string}>277 */278 private function mechanismHints(): array279 {280 $out = [];281 foreach ($this->mechanismCatalog() as $capability) {282 $out[] = [283 'capability' => (string) $capability['id'],284 'attribute' => (string) $capability['declared_by'],285 'use_when' => (string) $capability['use_when'],286 'avoid_when' => (string) $capability['avoid_when'],287 ];288 }289290 return $out;291 }292}293
1<?php23declare(strict_types=1);45namespace Semitexa\Dev\Application\Console\Command;67use Semitexa\Core\Attribute\AsCommand;8use Semitexa\Core\Console\BaseCommand;9use Semitexa\Dev\Application\Service\Ai\Similarity\DuplicateDetector;10use Semitexa\Dev\Application\Service\Ai\Similarity\DuplicateGate;11use Semitexa\Dev\Application\Service\Ai\Similarity\DuplicateQuery;12use Semitexa\Dev\Application\Service\Ai\Similarity\SimilarityIndexBuilder;13use Semitexa\Dev\Application\Service\Generation\Support\GenerationExitCode;14use Semitexa\Dev\Application\Service\Generation\Support\GenerationPreflight;15use Semitexa\Dev\Application\Service\Generation\Support\GenerationOutcomeRenderer;16use Semitexa\Dev\Application\Service\Generation\Builder\PayloadPlanBuilder;17use Semitexa\Dev\Application\Service\Generation\Support\JsonResultFormatter;18use Semitexa\Dev\Application\Service\Generation\Support\LlmHintsFormatter;19use Semitexa\Dev\Application\Service\Generation\Support\NameInflector;20use Semitexa\Dev\Application\Service\Generation\Support\ReplayArgBuilder;21use Semitexa\Dev\Application\Service\Generation\Support\TemplateRenderer;22use Semitexa\Dev\Application\Service\Generation\Support\TemplateResolver;23use Semitexa\Dev\Application\Service\Generation\Verifier\PostWriteLinter;24use Semitexa\Dev\Application\Service\Generation\Writer\SafeFileWriter;25use Symfony\Component\Console\Input\InputInterface;26use Symfony\Component\Console\Input\InputOption;27use Symfony\Component\Console\Output\OutputInterface;28use Symfony\Component\Console\Style\SymfonyStyle;2930#[AsCommand(name: 'make:payload', description: 'Scaffold a new Payload DTO class')]31final class MakePayloadCommand extends BaseCommand32{33 /**34 * Options execute() refuses to run without. Symfony's VALUE_REQUIRED only35 * requires a value when the option is given; presence is checked by36 * {@see GenerationPreflight}, and NextCommandContractTest reads this list.37 */38 public const REQUIRED_OPTIONS = ['module', 'name', 'path', 'method', 'response'];3940 protected function configure(): void41 {42 $this43 ->addOption('module', null, InputOption::VALUE_REQUIRED, 'Target module name')44 ->addOption('name', null, InputOption::VALUE_REQUIRED, 'Payload name without suffix')45 ->addOption('path', null, InputOption::VALUE_REQUIRED, 'Route path')46 ->addOption('method', null, InputOption::VALUE_REQUIRED, 'HTTP method')47 ->addOption('response', null, InputOption::VALUE_REQUIRED, 'Response class name without suffix')48 ->addOption('access', null, InputOption::VALUE_REQUIRED, 'Payload access type: public | protected | service', 'protected')49 ->addOption('graphql', null, InputOption::VALUE_NONE, 'Opt the Payload into GraphQL discovery (emits a bare #[ExposeAsGraphql] marker)')50 ->addOption('graphql-field', null, InputOption::VALUE_REQUIRED, 'Explicit GraphQL field name override (default: derived from the Payload class name)')51 ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show planned files without writing (explicit)')52 ->addOption('write', null, InputOption::VALUE_NONE, 'Actually create files (dry-run is the default)')53 ->addOption('force', null, InputOption::VALUE_NONE, 'Overwrite existing files')54 ->addOption('override-duplicate', null, InputOption::VALUE_NONE, 'Bypass duplicate/similarity refusal')55 ->addOption('json', null, InputOption::VALUE_NONE, 'Output as JSON')56 ->addOption('llm-hints', null, InputOption::VALUE_NONE, 'Output LLM hints envelope');57 }5859 protected function execute(InputInterface $input, OutputInterface $output): int60 {61 $io = new SymfonyStyle($input, $output);6263 $rejected = GenerationPreflight::check($input, $output, 'make:payload', self::REQUIRED_OPTIONS, $this->getProjectRoot());64 if ($rejected !== null) {65 return $rejected;66 }6768 $inflector = new NameInflector();69 $resolver = new TemplateResolver();70 $renderer = new TemplateRenderer();71 $builder = new PayloadPlanBuilder($inflector, $resolver, $renderer);7273 $plan = $builder->build([74 'module' => $input->getOption('module'),75 'name' => $input->getOption('name'),76 'path' => $input->getOption('path'),77 'method' => $input->getOption('method'),78 'response' => $input->getOption('response'),79 'access' => (string) $input->getOption('access'),80 'graphql' => (bool) $input->getOption('graphql'),81 'graphqlField' => $input->getOption('graphql-field'),82 'dryRun' => $input->getOption('dry-run') || !$input->getOption('write'),83 ]);8485 $module = $inflector->toStudly($input->getOption('module'));86 $payloadClassName = $inflector->toPayloadClass($input->getOption('name'));87 $payloadFqcn = "Semitexa\\Modules\\{$module}\\Application\\Payload\\Request\\{$payloadClassName}";88 $replayArgs = ReplayArgBuilder::fromInput($input, ['module', 'name', 'path', 'method', 'response', 'access', 'graphql-field'], ['graphql']);89 $duplicateGate = new DuplicateGate();90 $detector = new DuplicateDetector((new SimilarityIndexBuilder($this->getProjectRoot()))->build());91 $gateExit = $duplicateGate->run(92 new DuplicateQuery(93 kind: 'payload',94 module: $module,95 className: $payloadClassName,96 fqcn: $payloadFqcn,97 relativePath: $plan->files[0]->path,98 extras: [99 'route_path' => (string) $input->getOption('path'),100 'route_method' => strtoupper((string) $input->getOption('method')),101 ],102 ),103 $detector,104 $io,105 $output,106 (bool) $input->getOption('override-duplicate'),107 (bool) $input->getOption('json'),108 (bool) $input->getOption('llm-hints'),109 );110 if ($gateExit !== null) {111 return $gateExit;112 }113114 $plannedResult = new \Semitexa\Dev\Application\Service\Generation\Data\GenerationResult(115 command: 'make:payload',116 status: 'dry_run',117 created: array_map(static fn($file): string => $file->path, $plan->files),118 next_steps: ['Re-run with --write to create files'],119 replay_args: $replayArgs,120 );121122 if ($plan->dryRun) {123 if ($input->getOption('json')) {124 $output->writeln((new JsonResultFormatter())->format($plannedResult));125 return self::SUCCESS;126 }127128 if ($input->getOption('llm-hints')) {129 $module = $inflector->toStudly($input->getOption('module'));130 $name = $inflector->toStudly($input->getOption('name'));131 $formatter = new LlmHintsFormatter();132 $output->writeln($formatter->format('payload_scaffold', $plannedResult, [133 'fill_targets' => [134 "src/modules/{$module}/src/Application/Payload/Request/{$inflector->toPayloadClass($name)}.php" => [135 'Add properties for request parameters',136 'Throw Semitexa\\Core\\Exception\\ValidationException from setters to reject invalid input',137 ],138 ],139 'facts' => [140 'Payload classes are auto-discovered via #[AsPublicPayload] / #[AsProtectedPayload] / #[AsServicePayload] (one is required)',141 'Payload setters throw Semitexa\\Core\\Exception\\ValidationException to reject invalid input at hydration time',142 ],143 'constraints' => [144 'Do not add constructor — properties are hydrated via setters or public access',145 ],146 'suggested_next_prompt' => "Now create the handler: bin/semitexa make:handler --module={$module} --name={$name} --payload={$name} --resource={$name} --write",147 ]));148 return self::SUCCESS;149 }150151 $io->title('Dry Run — Planned Files');152 foreach ($plan->files as $file) {153 $io->section($file->path);154 $output->writeln($file->content);155 }156 return self::SUCCESS;157 }158159 $writer = new SafeFileWriter($this->getProjectRoot(), 'make:payload');160 $result = $writer->write($plan->files, (bool) $input->getOption('force'));161 $result = (new PostWriteLinter($this->getApplication()))->lintAfterWrite($result);162 $result = $result->withReplayArgs($replayArgs);163164 if ($input->getOption('json')) {165 $output->writeln((new JsonResultFormatter())->format($result));166 return GenerationExitCode::forResult($result);167 }168169 if ($input->getOption('llm-hints')) {170 $module = $inflector->toStudly($input->getOption('module'));171 $name = $inflector->toStudly($input->getOption('name'));172 $formatter = new LlmHintsFormatter();173 $output->writeln($formatter->format('payload_scaffold', $result, [174 'fill_targets' => [175 "src/modules/{$module}/src/Application/Payload/Request/{$inflector->toPayloadClass($name)}.php" => [176 'Add properties for request parameters',177 'Throw Semitexa\\Core\\Exception\\ValidationException from setters to reject invalid input',178 ],179 ],180 'facts' => [181 'Payload classes are auto-discovered via #[AsPublicPayload] / #[AsProtectedPayload] / #[AsServicePayload] (one is required)',182 'Payload setters throw Semitexa\\Core\\Exception\\ValidationException to reject invalid input at hydration time',183 ],184 'constraints' => [185 'Do not add constructor — properties are hydrated via setters or public access',186 ],187 'suggested_next_prompt' => "Now create the handler: bin/semitexa make:handler --module={$module} --name={$name} --payload={$name} --resource={$name} --write",188 ]));189 return GenerationExitCode::forResult($result);190 }191192 if ($result->created) {193 $io->success('Created: ' . implode(', ', $result->created));194 }195 GenerationOutcomeRenderer::renderProblems($io, $result);196197 return GenerationExitCode::forResult($result);198 }199}200
1<?php23declare(strict_types=1);45namespace Semitexa\Dev\Application\Console\Command;67use Semitexa\Core\Attribute\AsCommand;8use Semitexa\Core\Console\BaseCommand;9use Semitexa\Dev\Application\Service\Generation\Support\GenerationExitCode;10use Semitexa\Dev\Application\Service\Generation\Support\GenerationPreflight;11use Semitexa\Dev\Application\Service\Generation\Support\GenerationOutcomeRenderer;12use Semitexa\Dev\Application\Service\Generation\Builder\ServicePlanBuilder;13use Semitexa\Dev\Application\Service\Generation\Support\JsonResultFormatter;14use Semitexa\Dev\Application\Service\Generation\Support\LlmHintsFormatter;15use Semitexa\Dev\Application\Service\Generation\Support\NameInflector;16use Semitexa\Dev\Application\Service\Generation\Support\ReplayArgBuilder;17use Semitexa\Dev\Application\Service\Generation\Support\TemplateRenderer;18use Semitexa\Dev\Application\Service\Generation\Support\TemplateResolver;19use Semitexa\Dev\Application\Service\Generation\Verifier\PostWriteLinter;20use Semitexa\Dev\Application\Service\Generation\Writer\SafeFileWriter;21use Symfony\Component\Console\Input\InputInterface;22use Symfony\Component\Console\Input\InputOption;23use Symfony\Component\Console\Output\OutputInterface;24use Symfony\Component\Console\Style\SymfonyStyle;2526#[AsCommand(name: 'make:service', description: 'Scaffold a new service class with #[AsService]')]27final class MakeServiceCommand extends BaseCommand28{29 /**30 * Options execute() refuses to run without. Symfony's VALUE_REQUIRED only31 * requires a value when the option is given; presence is checked by32 * {@see GenerationPreflight}, and NextCommandContractTest reads this list.33 */34 public const REQUIRED_OPTIONS = ['module', 'name'];3536 protected function configure(): void37 {38 $this39 ->addOption('module', null, InputOption::VALUE_REQUIRED, 'Target module name')40 ->addOption('name', null, InputOption::VALUE_REQUIRED, 'Service class name')41 ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show planned files without writing (explicit)')42 ->addOption('write', null, InputOption::VALUE_NONE, 'Actually create files (dry-run is the default)')43 ->addOption('force', null, InputOption::VALUE_NONE, 'Overwrite existing files')44 ->addOption('json', null, InputOption::VALUE_NONE, 'Output as JSON')45 ->addOption('llm-hints', null, InputOption::VALUE_NONE, 'Output LLM hints envelope');46 }4748 protected function execute(InputInterface $input, OutputInterface $output): int49 {50 $io = new SymfonyStyle($input, $output);5152 $rejected = GenerationPreflight::check($input, $output, 'make:service', self::REQUIRED_OPTIONS, $this->getProjectRoot());53 if ($rejected !== null) {54 return $rejected;55 }5657 $inflector = new NameInflector();58 $resolver = new TemplateResolver();59 $renderer = new TemplateRenderer();60 $builder = new ServicePlanBuilder($inflector, $resolver, $renderer);61 $replayArgs = ReplayArgBuilder::fromInput($input, ['module', 'name']);6263 $plan = $builder->build([64 'module' => $input->getOption('module'),65 'name' => $input->getOption('name'),66 'dryRun' => $input->getOption('dry-run') || !$input->getOption('write'),67 ]);6869 $plannedResult = new \Semitexa\Dev\Application\Service\Generation\Data\GenerationResult(70 command: 'make:service',71 status: 'dry_run',72 created: array_map(static fn($file): string => $file->path, $plan->files),73 next_steps: ['Re-run with --write to create files'],74 replay_args: $replayArgs,75 );7677 if ($plan->dryRun) {78 if ($input->getOption('json')) {79 $output->writeln((new JsonResultFormatter())->format($plannedResult));80 return self::SUCCESS;81 }8283 if ($input->getOption('llm-hints')) {84 $module = $inflector->toStudly($input->getOption('module'));85 $name = $inflector->toStudly($input->getOption('name'));86 $formatter = new LlmHintsFormatter();87 $output->writeln($formatter->format('service_scaffold', $plannedResult, [88 'fill_targets' => [89 "src/modules/{$module}/src/Domain/Service/{$name}.php" => [90 'Add #[InjectAsReadonly] properties for dependencies',91 'Implement service methods',92 ],93 ],94 'facts' => [95 '#[AsService] makes it auto-discoverable and injectable via #[InjectAsReadonly]',96 'Services are worker-scoped singletons (shared across requests in one worker)',97 ],98 'constraints' => [99 'Use #[InjectAsReadonly] for dependencies, never constructor injection',100 'Service must be final class',101 ],102 ]));103 return self::SUCCESS;104 }105106 $io->title('Dry Run — Planned Files');107 foreach ($plan->files as $file) {108 $io->section($file->path);109 $output->writeln($file->content);110 }111 return self::SUCCESS;112 }113114 $writer = new SafeFileWriter($this->getProjectRoot(), 'make:service');115 $result = $writer->write($plan->files, (bool) $input->getOption('force'));116 $result = (new PostWriteLinter($this->getApplication()))->lintAfterWrite($result);117 $result = $result->withReplayArgs($replayArgs);118119 if ($input->getOption('json')) {120 $output->writeln((new JsonResultFormatter())->format($result));121 return GenerationExitCode::forResult($result);122 }123124 if ($input->getOption('llm-hints')) {125 $module = $inflector->toStudly($input->getOption('module'));126 $name = $inflector->toStudly($input->getOption('name'));127 $formatter = new LlmHintsFormatter();128 $output->writeln($formatter->format('service_scaffold', $result, [129 'fill_targets' => [130 "src/modules/{$module}/src/Domain/Service/{$name}.php" => [131 'Add #[InjectAsReadonly] properties for dependencies',132 'Implement service methods',133 ],134 ],135 'facts' => [136 '#[AsService] makes it auto-discoverable and injectable via #[InjectAsReadonly]',137 'Services are worker-scoped singletons (shared across requests in one worker)',138 ],139 'constraints' => [140 'Use #[InjectAsReadonly] for dependencies, never constructor injection',141 'Service must be final class',142 ],143 ]));144 return GenerationExitCode::forResult($result);145 }146147 if ($result->created) {148 $io->success('Created: ' . implode(', ', $result->created));149 }150 GenerationOutcomeRenderer::renderProblems($io, $result);151152 return GenerationExitCode::forResult($result);153 }154}155
1<?php23declare(strict_types=1);45namespace Semitexa\Dev\Application\Console\Command;67use Semitexa\Core\Attribute\AsCommand;8use Semitexa\Core\Console\BaseCommand;9use Semitexa\Dev\Application\Service\Generation\Support\GenerationExitCode;10use Semitexa\Dev\Application\Service\Generation\Support\GenerationPreflight;11use Semitexa\Dev\Application\Service\Generation\Support\GenerationOutcomeRenderer;12use Semitexa\Dev\Application\Service\Generation\Builder\ContractPlanBuilder;13use Semitexa\Dev\Application\Service\Generation\Support\JsonResultFormatter;14use Semitexa\Dev\Application\Service\Generation\Support\LlmHintsFormatter;15use Semitexa\Dev\Application\Service\Generation\Support\NameInflector;16use Semitexa\Dev\Application\Service\Generation\Support\ReplayArgBuilder;17use Semitexa\Dev\Application\Service\Generation\Support\TemplateRenderer;18use Semitexa\Dev\Application\Service\Generation\Support\TemplateResolver;19use Semitexa\Dev\Application\Service\Generation\Verifier\PostWriteLinter;20use Semitexa\Dev\Application\Service\Generation\Writer\SafeFileWriter;21use Symfony\Component\Console\Input\InputInterface;22use Symfony\Component\Console\Input\InputOption;23use Symfony\Component\Console\Output\OutputInterface;24use Symfony\Component\Console\Style\SymfonyStyle;2526#[AsCommand(name: 'make:contract', description: 'Scaffold a service contract interface + implementation')]27final class MakeContractCommand extends BaseCommand28{29 /**30 * Options execute() refuses to run without. Symfony's VALUE_REQUIRED only31 * requires a value when the option is given; presence is checked by32 * {@see GenerationPreflight}, and NextCommandContractTest reads this list.33 */34 public const REQUIRED_OPTIONS = ['module', 'name', 'implementation'];3536 protected function configure(): void37 {38 $this39 ->addOption('module', null, InputOption::VALUE_REQUIRED, 'Target module name')40 ->addOption('name', null, InputOption::VALUE_REQUIRED, 'Contract interface name (e.g., PaymentGateway)')41 ->addOption('implementation', null, InputOption::VALUE_REQUIRED, 'Implementation class name (e.g., StripePaymentGateway)')42 ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show planned files without writing (explicit)')43 ->addOption('write', null, InputOption::VALUE_NONE, 'Actually create files (dry-run is the default)')44 ->addOption('force', null, InputOption::VALUE_NONE, 'Overwrite existing files')45 ->addOption('json', null, InputOption::VALUE_NONE, 'Output as JSON')46 ->addOption('llm-hints', null, InputOption::VALUE_NONE, 'Output LLM hints envelope');47 }4849 protected function execute(InputInterface $input, OutputInterface $output): int50 {51 $io = new SymfonyStyle($input, $output);5253 $rejected = GenerationPreflight::check($input, $output, 'make:contract', self::REQUIRED_OPTIONS, $this->getProjectRoot());54 if ($rejected !== null) {55 return $rejected;56 }5758 $inflector = new NameInflector();59 $resolver = new TemplateResolver();60 $renderer = new TemplateRenderer();61 $builder = new ContractPlanBuilder($inflector, $resolver, $renderer);62 $replayArgs = ReplayArgBuilder::fromInput($input, ['module', 'name', 'implementation']);6364 $plan = $builder->build([65 'module' => $input->getOption('module'),66 'name' => $input->getOption('name'),67 'implementation' => $input->getOption('implementation'),68 'dryRun' => $input->getOption('dry-run') || !$input->getOption('write'),69 ]);7071 $plannedResult = new \Semitexa\Dev\Application\Service\Generation\Data\GenerationResult(72 command: 'make:contract',73 status: 'dry_run',74 created: array_map(static fn($file): string => $file->path, $plan->files),75 next_steps: ['Re-run with --write to create files'],76 replay_args: $replayArgs,77 );7879 if ($plan->dryRun) {80 $module = $inflector->toStudly($input->getOption('module'));81 $name = $inflector->toStudly($input->getOption('name'));82 $interfaceClass = str_ends_with($name, 'Interface') ? $name : $name . 'Interface';83 $implName = $inflector->toStudly($input->getOption('implementation'));8485 if ($input->getOption('json')) {86 $output->writeln((new JsonResultFormatter())->format($plannedResult));87 return self::SUCCESS;88 }8990 if ($input->getOption('llm-hints')) {91 $formatter = new LlmHintsFormatter();92 $output->writeln($formatter->format('contract_scaffold', $plannedResult, [93 'fill_targets' => [94 "src/modules/{$module}/src/Domain/Contract/{$interfaceClass}.php" => [95 'Define interface methods',96 ],97 "src/modules/{$module}/src/Domain/Service/{$implName}.php" => [98 'Implement all interface methods',99 'Add #[InjectAsReadonly] properties for dependencies',100 ],101 ],102 'facts' => [103 '#[SatisfiesServiceContract] auto-registers this implementation in the DI container',104 'If another module provides a competing implementation, module "extends" priority determines the winner',105 'Run bin/semitexa contracts:list to verify the active binding',106 ],107 'constraints' => [108 'Implementation must actually implement the interface',109 'Use #[InjectAsReadonly] for dependencies, never constructor injection',110 ],111 'suggested_next_prompt' => "Run: bin/semitexa contracts:list --json to verify the binding",112 ]));113 return self::SUCCESS;114 }115116 $io->title('Dry Run — Planned Files');117 foreach ($plan->files as $file) {118 $io->section($file->path);119 $output->writeln($file->content);120 }121 return self::SUCCESS;122 }123124 $writer = new SafeFileWriter($this->getProjectRoot(), 'make:contract');125 $result = $writer->write($plan->files, (bool) $input->getOption('force'));126 $result = (new PostWriteLinter($this->getApplication()))->lintAfterWrite($result);127 $result = $result->withReplayArgs($replayArgs);128129 if ($input->getOption('json')) {130 $output->writeln((new JsonResultFormatter())->format($result));131 return GenerationExitCode::forResult($result);132 }133134 if ($input->getOption('llm-hints')) {135 $module = $inflector->toStudly($input->getOption('module'));136 $name = $inflector->toStudly($input->getOption('name'));137 $interfaceClass = str_ends_with($name, 'Interface') ? $name : $name . 'Interface';138 $implName = $inflector->toStudly($input->getOption('implementation'));139 $formatter = new LlmHintsFormatter();140 $output->writeln($formatter->format('contract_scaffold', $result, [141 'fill_targets' => [142 "src/modules/{$module}/src/Domain/Contract/{$interfaceClass}.php" => [143 'Define interface methods',144 ],145 "src/modules/{$module}/src/Domain/Service/{$implName}.php" => [146 'Implement all interface methods',147 'Add #[InjectAsReadonly] properties for dependencies',148 ],149 ],150 'facts' => [151 '#[SatisfiesServiceContract] auto-registers this implementation in the DI container',152 'If another module provides a competing implementation, module "extends" priority determines the winner',153 'Run bin/semitexa contracts:list to verify the active binding',154 ],155 'constraints' => [156 'Implementation must actually implement the interface',157 'Use #[InjectAsReadonly] for dependencies, never constructor injection',158 ],159 'suggested_next_prompt' => "Run: bin/semitexa contracts:list --json to verify the binding",160 ]));161 return GenerationExitCode::forResult($result);162 }163164 if ($result->created) {165 $io->success('Created: ' . implode(', ', $result->created));166 }167 GenerationOutcomeRenderer::renderProblems($io, $result);168169 return GenerationExitCode::forResult($result);170 }171}172
Generator Rule
When scaffolding actually adds value
The point is not fewer keystrokes. The point is fewer incorrect architectural starts.
Use generators to establish the canonical file and attribute shape before implementation details creep in.
Prefer dry-run, JSON, or llm-hints when you want reviewers or agents to inspect the plan before files are written.
Treat generated files as architectural starting points, not as final code that excuses design thinking.
A scaffold is successful when it reduces wrong framework patterns, not only when it saves typing.
How it works
Commands like make:module, make:page, make:payload, make:service, and make:contract use builders and template resolvers to produce correctly placed files, with dry-run, JSON, and llm-hints modes where appropriate.
Why it matters
Good scaffolding is not just about speed. It teaches the expected architecture by generating the right boundaries and naming conventions from the start.
Key concepts
- make:page
- Scaffolds a complete page boundary: payload, handler, resource, and template.
- --llm-hints
- Outputs a machine-readable envelope describing what files were created and what should be implemented next.
- dry-run
- Lets the user inspect the generation plan before any files are written.