Gacela ships a console binary. In your project it is vendor/bin/gacela (or <vendor-dir>/bin/gacela when config.vendor-dir is set); run it with no arguments (or list) to see everything with its options.
vendor/bin/gacela # list every command
vendor/bin/gacela help doctor # options for one commandEvery command bootstraps the Composer project root. When run through Composer's binary proxy, it uses the root recorded with that autoloader; otherwise it finds the nearest directory holding vendor/autoload.php. init creates the optional gacela.php; without one, commands use Gacela's defaults with any configured overrides, and doctor names the root in an informational line in its text output, including with --only-problems.
| Command | What it does |
|---|---|
init |
Creates the optional gacela.php, plus the config/app.php it declares. --force regenerates gacela.php and never touches your config |
agents:install |
Points the project's AGENTS.md at the guide Gacela ships for coding agents, between markers it alone rewrites. See coding agents |
make:module App/Blog |
Generates a module. --template=basic|service|minimal, --minimal, --with-tests (service template only — refused on the others rather than ignored), --short-name, --force to replace files that already exist, --dry-run to see what it would write |
make:file App/Blog Facade Factory |
Generates named files into an existing module — the four pillars, plus any kind the project declared. --short-name, --force to replace files that already exist, --dry-run to see what it would write |
stubs:publish |
Copies the scaffolder's templates into the project so make:* generates your house style. --template=basic|service, --force, --dry-run |
make:module and make:file write into the directory your composer.json psr-4 maps the namespace to, including a fallback "" prefix, resolved from the project root, and print each path relative to that root. Run from a subdirectory, they write to the same place as from the root.
The path takes / or \ between segments: make:module App/Blog and make:module 'App\Blog' generate the same module. A leading backslash is accepted too.
make:module and make:file generate from templates that ship with gacela. stubs:publish copies them into the project — stubs/gacela/ by default, GacelaConfig::setStubsDir() to put them elsewhere:
vendor/bin/gacela stubs:publish # every stub
vendor/bin/gacela stubs:publish --template=basic # one template set
vendor/bin/gacela stubs:publish --force # replace ones already published
vendor/bin/gacela stubs:publish --dry-run # say what it would write, write nothing--dry-run works the same way on make:module and make:file. It resolves the target paths through the code that generates them, so the preview names the files the real run writes rather than a second guess at them — and it refuses where the real run refuses, so a preview over an existing module reports the refusal instead of listing files nothing would write.
From then on a generated file uses the project's stub when there is one and the built-in template when there is not — per file, so publishing your Facade stub does not freeze the Factory at the version it was copied from. Without --force nothing already published is overwritten: it is a file somebody changed on purpose.
Every stub substitutes $NAMESPACE$, $MODULE_NAME$ and $CLASS_NAME$. doctor reports a published stub that lost $NAMESPACE$ or $CLASS_NAME$, and one filed under a name the scaffolder does not read — an edit that never takes effect looks exactly like one that did.
A kind declared with addResolvableType() is a filename make:file accepts, listed in its help next to the pillars. An exact kind name or alias always means that kind, even when it also abbreviates another kind.
vendor/bin/gacela make:file App/Wallet Exporter # generates App/Wallet/WalletExporter.phpNothing ships for such a kind, so its stub is one you write: stubs/gacela/exporter-maker.txt, the kind's name lower-cased and hyphenated where it changes case. Until that file exists make:file says so and names the path it looked for — stubs:publish has nothing to copy there. make:module still scaffolds the four pillars only; a declared kind is generated one file at a time.
| Command | What it does |
|---|---|
migrate:service-map |
Writes the #[ServiceMap] attribute the gacela.serviceMapMissing analysis reports as missing, for every class at once. Resolving a pillar accessor from its @method docblock is deprecated in 2.x and removed in 3.0, and the runtime notice only fires for accessors a run actually reaches, on a cold resolve, so a migration driven by notices covers the code paths your tests happen to execute. --dry-run reports what would change and writes nothing; an optional argument narrows to paths containing it. Only the attribute and, when missing, the import are added, the import in the sorted position ordered_imports expects; nothing else in the file moves, and running it twice changes nothing. A file is replaced only once its new content is fully written; a failed write leaves that file as it was |
list:modules |
Renders every module found. --detailed for one block per module; --json for the whole inventory with every pillar, filter and all. Text output names configured paths that are not directories |
debug:modules |
Dependency resolvability of every module pillar. --detail for every parameter rather than only the unresolvable ones; --check exits non-zero when the container cannot satisfy one, for CI; --json for the same report as a document |
debug:module App/Blog |
One module: resolved classes, the ids its Provider declares with #[Provides], the classes it exports as public API, container bindings, dependency tree. --tree prints only the dependency tree; --json for a script |
debug:provides ID |
Which Provider declares an id with #[Provides], across every module — the inverse of getProvidedDependency(), which answers null for an id nothing declares and says nothing about it. The argument narrows to ids containing it; --json for a script |
debug:config |
The effective merged configuration, after every source and override, each key marked declared, undeclared or missing against the schema. --json (or --format=json) keeps the values as their own types, for diffing one environment against another |
debug:container |
Container contents — user bindings and plugins only. A class argument (or --tree) draws its dependency tree; --json for either as a document. --stats also names each installed package that contributed configuration, the file that ran and what it declared, which is the one source in an application a reader cannot find by searching it |
debug:dependencies Foo::class |
A class's constructor parameters and whether the container can supply each. --tree walks the whole graph and marks every node binding, instance, autowired or unresolvable; --json for either as a document |
debug:graph |
The module dependency graph. --format=text|mermaid|graphviz|json (--json for the last), --check to fail on cycles, --compare-to to write a markdown diff. --allowed-cycles and --rules are read only by --check, and are refused without it; --compare-to refuses all three, so run the check as its own step, and takes no --format or --json |
debug:events |
Every event the framework can dispatch, which of them anything listens to, and which are dispatched on the class-resolution hot path. A listener matches by inheritance, so an event can be covered by a registration that never names it — the listener column names the target that does. An optional argument narrows to class names containing it; --listened to only the events something watches; --json for a script |
debug:plugins |
Each plugin stack's members and each tag's ids, in the order they are read, and the #[AsListener] methods, with where each was declared: gacela.php (packages included) or the attribute. Ids a module's Provider tags at runtime stay in that module's container, after these, and are not listed. A #[Plugin] naming a stack nobody declared is listed as never read. --json for the same report as a document. |
Two of these are built for CI. debug:graph --check fails on a dependency cycle — see module boundaries for wiring it into a workflow — and debug:modules --check fails when a pillar needs a constructor parameter the container cannot satisfy. Both pair with --json, so a job can say which module and which parameter rather than only that something failed; debug:modules --json carries a faults count apart from unresolvable, which is the distinction --check fails on.
--check fails only on parameters the inspector looked at and rejected: an unbound interface, a scalar with no default, a missing type hint, a type that does not exist. A union or intersection type is not walked, so it is reported as unresolvable and does not fail the run — that is a gap in the tool rather than a fault in your project. The count of those is printed, so a passing --check does not read as "every parameter was checked".
Every command above finds modules the same way: it walks the project for .php files, loads each one, and keeps the classes that descend from AbstractFacade. Hidden directories, vendor/ and node_modules/ are skipped; everything else is descended into, because guessing that a project's build/ or data/ holds no modules is how discovery starts silently missing them.
With no configuration that walk starts at the project root. setAppModulePaths() narrows it to the directories your modules actually live in:
// gacela.php
return static function (GacelaConfig $config): void {
$config->setAppModulePaths(['src']);
};It is worth setting. On this repository, restricting the scan to the directories that hold modules cuts the walk from 14,290 filesystem entries to 1,826 and discovery from roughly 220 ms to 157 ms, finding the same modules either way — the difference is entirely docs/, tools/, build/ and friends being walked and rejected. A project with a large var/, storage/ or public/ tree saves more.
Paths are relative to the app root. Every module has to be under one of them: a Facade outside the listed paths is not found, and nothing reports it as missing, so widen the list rather than trimming it to the minimum.
This is a CLI and cache:warm cost, not a request-time one — resolving a Facade at runtime does not walk anything.
| Command | What it does |
|---|---|
doctor |
Environmental and wiring health checks, including the declared config schema and each package's composer.json against what it imports. Takes an optional namespace to restrict module-scoped checks, --strict to exit non-zero on warnings too, --only-problems to print just the checks that found something, and --format=json (or --json) for a CI job that wants to say which check failed rather than only that one did. Every run names the scope it ran under, because every check works from the modules discovery returned and anything narrowing that narrows all of them at once: the paths walked (Scanned: src), any configured entry that is not a directory and so scanned nothing (Not scanned, not a directory:), and the filter with how many modules it matched, including when it matched none, which otherwise leaves every module-scoped check reporting "no modules discovered" and the run ending in All checks passed. --format=json carries the same four as scanned, unscanned, filter and modules |
validate:config |
Checks the configuration for errors and best-practice violations, and against the declared schema. --strict exits non-zero on warnings too, as on doctor; --json (or --format=json) reports which check found what |
Every command that can emit JSON accepts --json. Where a command also takes --format=, --json is shorthand for --format=json.
The three debug: commands that describe a class — debug:modules, debug:dependencies and debug:container — use one vocabulary in --json: a parameter is always name (without its $), type, status, detail and resolvable, and a tree node is always class, parameter, status, repeated and children. So a script that reads one reads the others.
A value none of them accepts is refused, naming the ones that would have worked, and exits non-zero. profile:report --sort answers the same way. That matters for a pipeline: --format=jsno used to print the human-readable report and exit 0, so the run that produced no document at all was indistinguishable from a healthy one.
doctor also runs any check you registered with GacelaConfig::addHealthCheck() — see module health checks.
Nine of the built-in checks report the same kind of fault: configuration a project declared that nothing acts on. Each is accepted at bootstrap, costs nothing at runtime, and does exactly nothing — which is why a command has to be the one to say so. Under --strict any of them fails the run.
- event listeners — a
registerSpecificListener()target that is no type at all: a typo, or a class that moved. The dispatcher matches by inheritance, so a parent class or an interface is a legitimate target and is left alone — it covers the family below it — and so is a concrete class nothing dispatches, because that listener is waiting, not broken. What is left is a name nothing can ever be, accepted at bootstrap and never run. It also reports listeners registered whiledisableEventListeners()is in effect: no dispatcher is built, so every one of them is inert, and that is the first thing to suspect when a listener appears dead. - config sources — an
addAppConfig()path matching no file.conf/*.phpfor a directory namedconfigbootstraps perfectly, and then the first read of a key fails with an error about the key rather than the path meant to provide it. Only the base path is reported:config/app-prod.phpis meant to be absent where it does not apply. - module paths: an
appModulePathsentry that is not a directory. Discovery skips it, and every module-scoped check works from the modules discovery returned, so one mistyped entry narrows all of them at once and the run still ends in a screen of ticks. The report names the skipped entry, andScanned:/Not scannedabove the checks show the same split. - cache directory — caching enabled onto a directory that cannot be written, or a cache-file write failed during this process. Writing is best-effort by design, so the application runs correctly and pays the cold cost on every request instead. Reported read-only:
doctornever creates the directory to find out. - duplicate provided ids — one
#[Provides]id declared twice on the same Provider.ProvidesScanner::scan()set()s them in order, so the last wins and every method before it is dead: both read as live, and the only symptom is a value that is not the one the method you are looking at returns. Reported per Provider, because that is where it is unambiguous — each module resolves through its own container, so the same id in two modules is two modules answering for themselves, not a collision. - unusable
#[Provides]— a declaration that cannot supply what it promises, in any of the three ways PHP accepts. The attribute on a private or protected method:ProvidesScannerreads public methods only, so the method reads as a declared service and nothing is registered. A method with a required parameter: it is registered and then called with none, so resolving the id raisesArgumentCountErrorat whatever point something first asks for it. A method returningvoidornever: the id is registered and answersnull. The first and third are silent —getProvidedDependency()answersnull, so the first sign is a call on null somewhere else entirely — and the second is loud but late, landing on the consumer rather than the declaration. - service extensions — an
extendService()id no Provider everset()s. A typo, or an id registered only throughbind()/singleton(), which do not drain the extension queue. AnextendProviderService()id is held against the Provider it names, which is the sharper miss: not "nobody set this id" but "the Provider you named does not", so an id some other Provider registers is still reported. It also reports an extended id thataddLazy()oraddFactory()registers at application level and a Providerset()s: a module scope skips extensions on ids the application container provides, so that module gets its Provider's value unextended. See which registration wins. - discovered packages — a
dontDiscover()entry that refuses nothing. Either it names a package that declares noextra.gacela.config— a typo, or a package that stopped shipping one — or it is written ingacela-{APP_ENV}.php, which is merged after the packages, so the config it names ran and the entry only looks like it worked. The same check reports the other half of package discovery: a declared config file that is not there, or one that does not return acallable(GacelaConfig). Those are skipped at boot on purpose —composer requiremust never be able to stop an application from booting — which is exactly why something has to say so afterwards. - tagged services — a
tag()id nothing can answer.Container::tagged()resolves each id in turn and gives backnullfor one naming nothing, so the group a module iterates carries a hole and the failure lands on the consumer as "Call to a member function … on null", pointing at the loop rather than the registration. An id is answerable when a Providerset()s it or it names a class the container can construct, so a tag grouping plain service ids is left alone.
The config environment layers check names the files a base config path matched that the base layer does not read. addAppConfig('config/*.php') globs literally, so it also matches the environment files Gacela names itself, and the base layer excludes them by name. That rule reads filenames and a filename carries no intent, so a config/app-extra.php written for some other purpose is excluded too and read only where the chain resolves to extra. Every exclusion is therefore reported: the file, the base file it is taken to refine, and the values that put it in play (read only when APP_ENV=prod and APP_REGION=eu). It is a pass, not a warning — for any project using APP_ENV or a dimension this is what correct looks like — so the remediation is there for the one case it cannot distinguish: rename a file that is not an environment layer, or give it its own addAppConfig() path.
A separate check answers the opposite question — a module that was never found at all. Every other check works from the modules discovery returned, so one it skipped is invisible to all of them, and list:modules names the cause only when nothing was discovered. One broken Facade in fifty leaves forty-nine modules and silence.
The undiscovered facades check reports a file named the way the scaffolder names a module's Facade, Blog/BlogFacade.php, or Blog/Facade.php for --short-name, under any configured suffix, that produced no module. Either PHP cannot load the class (a psr-4 prefix that does not cover the directory, a namespace disagreeing with the path, a classmap never dumped again, a syntax error, a parent class that does not exist) or it loads and does not extend AbstractFacade, which is the extends nobody wrote on a new module. Those have different fixes, so the remediation names the right one. PHP load errors include the message, file and line; other checks still run.
It is a warning, not an error: Facade is an ordinary word, and a project beside a framework with its own facades should not have a build failed by a naming coincidence. --strict is how a project opts in. Only names matching the scaffolder's own pattern count, so a NullFacade is left alone here — the GacelaSuffixExtends rule is what has an opinion about naming, and this check is about the module that went missing.
The unresolved pillar files check asks the same question one level down. A module whose BlogFactory.php did not resolve is still a module: the Facade resolves, discovery keeps it, and the Factory simply comes back null — so list:modules prints a blank cell and debug:module says (not found), telling you that you have no Factory while you are looking at the file you wrote. It reports a pillar file that is on disk and resolved to nothing, under any configured suffix and for --short-name modules too. A module that genuinely has no Factory is the ordinary case and is left alone; the check is about the file.
Autoloading is one of the two reasons a pillar comes back null, and the other is the pillar's own constructor: an unbound interface throws DependencyNotFoundException from a class whose namespace is right, whose psr-4 prefix is right, and which loads perfectly. So whatever was thrown is named on the line, and the remediation only offers the namespace and composer dump-autoload advice when the class genuinely is not there.
The cacheable storage check reports #[Cacheable] methods running on the default backend. That storage lives and dies with the process, so under PHP-FPM a method annotated for an hour's TTL is recomputed every request and the attribute buys nothing — silently, because a cache that misses looks exactly like one that was never asked to hold anything. It is a warning, not an error: the default is right wherever the process outlives the work, such as a CLI batch or a queue worker. Register a cross-request backend with CacheableConfig::setStorage(), or leave it and let --strict decide.
The plugin stacks check validates what addPluginStack() registered. A stack resolves lazily, so a class that does not exist or does not implement its contract is only refused when something first iterates that stack — which may be a path CI never takes. The check asks the same two questions at deploy time, and reports them apart, because a missing class read as one that "does not implement" the contract sends you to inspect an implements clause on a file that is not there.
The plugin, tag and listener attributes check asks the same of the #[Plugin] classes, plus one question the central list cannot raise: a #[Plugin] naming a contract that gacela.php declares no stack for is never read, so the class looks registered and is not. It is an error. Outside APP_ENV=dev|local|test, #[Plugin], #[Tag] or #[AsListener] members found by scanning because cache:warm --attributes has not run are a warning. A membership cache file that does not parse or lacks the plugin list is read as not warmed and rewritten on the next runtime scan.
The handler registries check asks the same of addHandlerRegistry(), and the case is stronger. A registry is looked up by key, so the keys a run does not take are exactly the registrations nobody has checked. For a missing class with no container binding, LazyHandlerRegistry throws HandlerRegistryException when the key is requested; its message names the key and class and includes autoloading tips. A class that does not implement the contract is handed back, and the failure surfaces as a call to a method it does not have. Every finding names the key, because the same handler can be registered under several and the key is what a consumer asks for.
| Command | What it does |
|---|---|
cache:warm |
Pre-resolves every module class and populates the on-disk caches. --clear first, --attributes to pre-scan #[ServiceMap] and store the #[Plugin], #[Tag] and #[AsListener] members |
cache:clear |
Removes every Gacela cache file |
dto:generate |
Writes the classes declared with declareDtoSchema(). Files it did not generate are left alone; --force replaces them. --dry-run reports without writing; --check does the same and exits non-zero when a class is stale, for CI. See DTO schema |
ide:meta |
Writes editor metadata typing getProvidedDependency() from the #[Provides] attributes. --dry-run reports without writing. See IDE metadata |
profile:report |
Performance profiling report. --format=table|json|summary (--json for the second), --sort=duration|memory|operation. The recording side is the Profiler |
cache:warm only writes anything when the file cache is enabled; the deploy sequence is in production performance. Both cache commands name files they could not write or remove and exit with status 1. With file caching disabled, cache:warm keeps its warning and exits successfully.