← CLI

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
make:module make:page make:payload make:service make:contract --llm-hints

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."

make:module Command Implementation slice
<?phpdeclare(strict_types=1);namespace Semitexa\Dev\Application\Console\Command;use Semitexa\Core\Attribute\AsCommand;use Semitexa\Core\Console\BaseCommand;use Semitexa\Dev\Application\Service\Generation\Support\GenerationPreflight;use Semitexa\Dev\Application\Service\Generation\Support\GenerationExitCode;use Semitexa\Dev\Application\Service\Generation\Support\GenerationOutcomeRenderer;use Semitexa\Dev\Application\Service\Generation\Builder\ModulePlanBuilder;use Semitexa\Dev\Application\Service\Generation\Support\JsonResultFormatter;use Semitexa\Dev\Application\Service\Generation\Support\LlmHintsFormatter;use Semitexa\Dev\Application\Service\Generation\Support\NameInflector;use Semitexa\Dev\Application\Service\Generation\Support\ReplayArgBuilder;use Semitexa\Dev\Application\Service\Generation\Verifier\PostWriteLinter;use Semitexa\Dev\Application\Service\Generation\Writer\SafeFileWriter;use Symfony\Component\Console\Input\InputInterface;use Symfony\Component\Console\Input\InputOption;use Symfony\Component\Console\Output\OutputInterface;use Symfony\Component\Console\Style\SymfonyStyle;#[AsCommand(name: 'make:module', description: 'Scaffold a new module with the standard directory structure')]final class MakeModuleCommand extends BaseCommand{    /** @see MakePageCommand::REQUIRED_OPTIONS */    public const REQUIRED_OPTIONS = ['name'];    private const TARGET_CUSTOM = ModulePlanBuilder::TARGET_CUSTOM;    private const TARGET_PACKAGE = ModulePlanBuilder::TARGET_PACKAGE;    private ?NameInflector $inflector = null;    protected function configure(): void    {        $this            ->addOption('name', null, InputOption::VALUE_REQUIRED, 'Module name (e.g., Catalog)')            ->addOption('target', null, InputOption::VALUE_REQUIRED, 'Module target: custom (src/modules) or package (packages/semitexa-...)')            ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show planned files without creating them (explicit)')            ->addOption('write', null, InputOption::VALUE_NONE, 'Actually write files (dry-run is the default)')            ->addOption('force', null, InputOption::VALUE_NONE, 'Overwrite existing files')            ->addOption('json', null, InputOption::VALUE_NONE, 'Output as JSON')            ->addOption('llm-hints', null, InputOption::VALUE_NONE, 'Output LLM hints envelope');    }    protected function execute(InputInterface $input, OutputInterface $output): int    {        $io = new SymfonyStyle($input, $output);        $rejected = GenerationPreflight::check($input, $output, 'make:module', self::REQUIRED_OPTIONS, $this->getProjectRoot());        if ($rejected !== null) {            return $rejected;        }        $this->inflector = new NameInflector();        $builder = new ModulePlanBuilder($this->inflector);        $target = $this->resolveTarget($input, $io);        if ($target === null) {            return GenerationPreflight::reject($input, $output, 'make:module', GenerationPreflight::REASON_INVALID_OPTION, 'Invalid --target. Allowed values: custom, package.');        }        $replayArgs = $this->buildReplayArgs($input, $target);        $module = $this->inflector->toStudly($input->getOption('name'));        $plan = $builder->build([            'name' => $input->getOption('name'),            'target' => $target,            'dryRun' => $input->getOption('dry-run') || !$input->getOption('write'),        ]);        $plannedResult = new \Semitexa\Dev\Application\Service\Generation\Data\GenerationResult(            command: 'make:module',            status: 'dry_run',            created: array_map(static fn($file): string => $file->path, $plan->files),            next_steps: ['Re-run with --write to create files'],            replay_args: $replayArgs,        );        if ($plan->dryRun) {            if ($input->getOption('json')) {                $output->writeln((new JsonResultFormatter())->format($plannedResult));                return self::SUCCESS;            }            if ($input->getOption('llm-hints')) {                $formatter = new LlmHintsFormatter();                $output->writeln($formatter->format('module_scaffold', $plannedResult, [                    'facts' => $this->buildFacts($module, $target),                    'suggested_next_prompt' => $this->buildSuggestedNextPrompt($module, $target),                ]));                return self::SUCCESS;            }            $io->title('Dry Run — Planned Files');            foreach ($plan->files as $file) {                $io->text($file->path);            }            return self::SUCCESS;        }        $writer = new SafeFileWriter($this->getProjectRoot(), 'make:module');        $result = $writer->write($plan->files, (bool) $input->getOption('force'));        $result = (new PostWriteLinter($this->getApplication()))->lintAfterWrite($result);        $result = $result->withReplayArgs($replayArgs);        if ($input->getOption('json')) {            $output->writeln((new JsonResultFormatter())->format($result));            return GenerationExitCode::forResult($result);        }        if ($input->getOption('llm-hints')) {            $formatter = new LlmHintsFormatter();            $output->writeln($formatter->format('module_scaffold', $result, [                'facts' => $this->buildFacts($module, $target),                'suggested_next_prompt' => $this->buildSuggestedNextPrompt($module, $target),            ]));            return GenerationExitCode::forResult($result);        }        if ($result->created) {            $io->success(sprintf(                'Module %s created as a %s module in %s.',                $module,                $target,                $this->targetRootLabel($module, $target),            ));            $io->text($this->buildNextText($target));        }        GenerationOutcomeRenderer::renderProblems($io, $result);        return GenerationExitCode::forResult($result);    }    private function resolveTarget(InputInterface $input, SymfonyStyle $io): ?string    {        $target = $input->getOption('target');        if (is_string($target)) {            if ($target === '') {                return null;            }            if (in_array($target, [self::TARGET_CUSTOM, self::TARGET_PACKAGE], true)) {                return $target;            }            return null;        }        if (!$input->isInteractive() || $input->getOption('json') || $input->getOption('llm-hints')) {            return self::TARGET_CUSTOM;        }        $io->section('Choose module target');        $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.');        $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.');        $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.');        $io->text('Both options follow the same Semitexa module structure. The real difference is ownership and where the module lives.');        return $io->choice(            'Which target should `make:module` scaffold?',            [self::TARGET_CUSTOM, self::TARGET_PACKAGE],            self::TARGET_CUSTOM,        );    }    /**     * @return list<string>     */    private function buildReplayArgs(InputInterface $input, string $target): array    {        $args = ReplayArgBuilder::fromInput($input, ['name']);        $args[] = '--target=' . $target;        return $args;    }    /**     * @return list<string>     */    private function buildFacts(string $module, string $target): array    {        if ($target === self::TARGET_PACKAGE) {            $slug = $this->moduleSlug($module);            return [                sprintf('Composer package: semitexa/%s', $slug),                sprintf('Source root: packages/semitexa-%s/src', $slug),                sprintf('Module namespace: Semitexa\\%s', $module),                'Use this when the module should be reusable, versioned, or shipped independently.',                'Package mode scaffolds the module shell only; downstream generators still target src/modules until package-aware follow-up support lands.',            ];        }        return [            sprintf('Module namespace: Semitexa\\Modules\\%s', $module),            sprintf('Source root: src/modules/%s/src', $module),            sprintf('Tests root: src/modules/%s/tests', $module),            'All directories follow the standard convention and are auto-discovered.',            'Use this when the module is app-specific and does not need its own package lifecycle.',        ];    }    private function targetRootLabel(string $module, string $target): string    {        if ($target === self::TARGET_PACKAGE) {            return sprintf('packages/semitexa-%s', $this->moduleSlug($module));        }        return sprintf('src/modules/%s', $module);    }    private function moduleSlug(string $module): string    {        return ($this->inflector ?? new NameInflector())->toKebab($module);    }    private function buildSuggestedNextPrompt(string $module, string $target): string    {        if ($target === self::TARGET_PACKAGE) {            return "Review the package scaffold for {$module} and add package-specific code manually until the downstream generators become target-aware.";        }        return "Use make:page, make:service, or make:contract to add code to the {$module} module.";    }    private function buildNextText(string $target): string    {        if ($target === self::TARGET_PACKAGE) {            return 'Next: package mode currently scaffolds the shell only; add files manually until the downstream generators support package targets.';        }        return 'Next: use make:page, make:service, or make:contract to add code.';    }}

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.

Support Semitexa
Built for developers who prefer control over magic. Your support helps keep it fast, open, and evolving.

Donate via PayPal