Use this guide when you already understand the basic idea of a custom schematic and want the details.
This page is for the point where "I can generate one file" is no longer enough and you want to design generators that reflect real application and team workflows.
That includes:
- how to design a manifest from scratch
- how tokens behave in paths and file contents
- how to scaffold more than one file type
- when declarative schematics stop being enough
- when to move to PHP-backed generation logic
If the introductory guide explains why custom schematics matter, this guide explains how to make them reliable and scalable.
The mental model
Think about a schematic in three layers:
- inputs
- templates
- targets
Inputs are the values the developer passes on the command line.
Templates are the source files inside the schematic folder.
Targets are the generated file paths inside the app.
If those three pieces are clear, the schematic is usually easy to reason about.
That is the real design skill here: not "how do I copy files?" but "what repeated workflow am I encoding into the CLI?"
Manifest reference
This is a realistic declarative manifest with several moving parts:
{
"name": "menu-sync",
"aliases": ["ms"],
"description": "Generate menu sync scaffolding.",
"requiresWorkspace": true,
"kind": "declarative",
"arguments": [
{
"name": "name",
"description": "The feature name to generate.",
"required": true
}
],
"options": [
{
"name": "provider",
"shortcut": "P",
"description": "The external menu provider name.",
"acceptValue": true,
"valueRequired": true,
"default": "internal"
},
{
"name": "with-client",
"description": "Generate a TypeScript client config too.",
"acceptValue": false,
"valueRequired": false
}
],
"templates": [
{
"source": "templates/service.php.stub",
"target": "__SOURCE_ROOT__/__NAME__/__NAME__Service.php"
},
{
"source": "templates/config.json.stub",
"target": "config/__KEBAB__.json"
},
{
"source": "templates/client.ts.stub",
"target": "src/frontend/__KEBAB__.client.ts"
}
]
}
How to read it:
nameis the command name afterassegai galiasesadds shortcutsargumentsdefine positional valuesoptionsdefine named flagstemplatesdefine what gets copied and where it goes
In v1, declarative schematics are intentionally limited to file generation and token replacement. They do not perform custom PHP logic or module mutation.
That limitation is useful. It keeps simple schematics simple.
Token reference
These tokens are available in declarative templates and in helper methods on AbstractCustomSchematic.
Naming tokens
__NAME__
The resolved feature name in PascalCase.__SINGULAR__
The singular PascalCase form.__PLURAL__
The plural PascalCase form.__CAMEL__
The feature name in camelCase.__KEBAB__
The feature name in kebab-case.__PASCAL__
The feature name in PascalCase.
Path and namespace tokens
__SOURCE_ROOT__
Usuallysrc__BASE_NAMESPACE__
The app's root PSR-4 namespace__CURRENT_NAMESPACE__
The namespace that matches the generated target path
Input tokens
__ARG_<NAME>____OPTION_<NAME>__
Examples:
__ARG_NAME____OPTION_PROVIDER____OPTION_DOMAIN__
These tokens are the bridge between a command and the generated output. They let one command produce code that already fits the structure of the workspace.
Combining tokens
Yes, you can combine them.
Token replacement is just string replacement. That means both of these are valid:
__SOURCE_ROOT__/__NAME__/DTOs/Create__SINGULAR__DTO.php
__SOURCE_ROOT__/__NAME__/Integrations/__PASCAL____OPTION_PROVIDER__Sync.php
And this is valid inside file contents too:
class __PASCAL____OPTION_PROVIDER__Client
{
}
If the user runs:
assegai g menu-sync rewards --provider=UberEats
then:
__SOURCE_ROOT__/__NAME__/Integrations/__PASCAL____OPTION_PROVIDER__Sync.php
becomes:
src/Rewards/Integrations/RewardsUberEatsSync.php
The same idea works in templates, filenames, folder names, and support files.
Generating multiple file types
A schematic can generate any text file, not only PHP.
Example template list:
[
{
"source": "templates/service.php.stub",
"target": "__SOURCE_ROOT__/__NAME__/__NAME__Service.php"
},
{
"source": "templates/provider.ts.stub",
"target": "frontend/providers/__KEBAB__.ts"
},
{
"source": "templates/config.yaml.stub",
"target": "config/__KEBAB__.yaml"
},
{
"source": "templates/runbook.md.stub",
"target": "docs/runbooks/__KEBAB__.md"
}
]
That is useful when a company workflow spans backend code, front-end support files, documentation, or operational config.
A good schematic often reflects the real slice of work a team repeats, not just one class file.
Example: one command, several outputs
Imagine a menu-sync schematic that needs:
- a service class
- a DTO
- a TypeScript provider config
- a Markdown integration note
Manifest:
{
"name": "menu-sync",
"kind": "declarative",
"requiresWorkspace": true,
"arguments": [
{
"name": "name",
"description": "The feature name to generate.",
"required": true
}
],
"options": [
{
"name": "provider",
"description": "The external provider name.",
"acceptValue": true,
"valueRequired": true,
"default": "internal"
}
],
"templates": [
{
"source": "templates/service.php.stub",
"target": "__SOURCE_ROOT__/__NAME__/__NAME__Service.php"
},
{
"source": "templates/dto.php.stub",
"target": "__SOURCE_ROOT__/__NAME__/DTOs/Create__SINGULAR__SyncDTO.php"
},
{
"source": "templates/provider.ts.stub",
"target": "frontend/providers/__KEBAB__.provider.ts"
},
{
"source": "templates/readme.md.stub",
"target": "docs/integrations/__KEBAB__.md"
}
]
}
Run:
assegai g menu-sync catalog --provider=uber-eats
That one command can create a small, repeatable integration slice instead of one PHP file.
This is where custom schematics become strategically useful. They can encode a company workflow, not just a framework primitive.
Designing good arguments and options
A good argument is something the developer almost always has to provide.
Typical examples:
- the feature name
- the domain name
- the provider name
A good option is something that changes the output shape or content.
Typical examples:
--provider=uber-eats--domain=finance--with-client
Practical rule:
- use an argument for the main subject
- use options for variations
That keeps commands readable and makes the generated output easier to predict.
When declarative stops being enough
Move to a class-backed schematic when:
- some files should only be written when an option is present
- the target path depends on rules you cannot express cleanly as tokens
- you need to compose content before writing it
- generation should branch by company-specific logic
Class-backed schematics use:
use Assegai\Console\Core\Schematics\Custom\AbstractCustomSchematic;
and receive a SchematicContext.
That context gives you:
- arguments
- options
- workspace path
- naming tokens
- output helpers
This is the moment where a schematic stops being "template copy plus replacement" and becomes "generation logic the team can maintain intentionally."
Minimal class-backed example
<?php
namespace Assegai\App\Schematics;
use Assegai\Console\Core\Schematics\Custom\AbstractCustomSchematic;
class MenuSyncSchematic extends AbstractCustomSchematic
{
public function build(): int
{
$provider = (string) $this->context()->getOption('provider', 'internal');
$template = $this->loadTemplate('templates/service.php.stub');
$content = $this->replaceTokens($template . PHP_EOL . '// Provider: __OPTION_PROVIDER__' . PHP_EOL);
return $this->writeRelativeFile(
'__SOURCE_ROOT__/__NAME__/Integrations/__PASCAL__' . ucfirst($provider) . 'Sync.php',
$content
);
}
}
That is the point where you stop thinking in terms of "copy this file" and start thinking in terms of "run generation logic."
Package-backed schematics
If a team uses the same schematic in many projects, ship it through Composer.
Register the manifests in the package:
{
"extra": {
"assegai": {
"schematics": [
"resources/menu-sync/schematic.json",
"resources/loyalty/schematic.json"
]
}
}
}
Then install the package in the workspace and use assegai schematic:list to verify that the CLI discovered it.
This is one of the most practical ways to scale schematics across a company. Instead of documenting conventions in a wiki, you can distribute them as executable tooling.
Troubleshooting
If a schematic is not showing up:
- run
assegai schematic:list - confirm the manifest path is correct
- confirm the workspace
assegai.jsonhas not disabled local or package discovery - confirm the manifest name or aliases do not collide with a built-in schematic
- confirm class-backed schematics point to a real handler class
If generation runs but the output is wrong:
- inspect the
targetpath first - inspect token names next
- remember that token replacement is text-based, so spelling matters exactly
Choosing the right schematic style
Use a declarative schematic when:
- the generator is mostly template-driven
- output paths are predictable
- token replacement is enough
- you want the simplest maintenance model
Use a class-backed schematic when:
- the generator needs branching behavior
- files are conditional
- content must be assembled in code
- output rules depend on real logic
A useful pattern is to begin declarative and move to PHP only when the generation rules have clearly outgrown templates.
Practical takeaway
Custom schematics are not just for framework authors.
They are useful for any team that wants to encode repeatable development patterns into the CLI.
That can include:
- domain scaffolds
- partner integrations
- tenant setup flows
- package starter kits
- internal platform conventions
- multi-file feature slices that span backend, frontend, config, and docs
The deeper point is simple: the more often your team repeats a pattern, the more value there is in making it a command.