Static analysis
Gacela ships PHPStan rules, Psalm configuration, and a 2.0 Psalm plugin for dynamic pillar accessors and module architecture.
PHPStan
Include in your phpstan.neon:
includes:
- vendor/gacela-project/gacela/phpstan-gacela.neonphpstan-gacela.neon types declared accessors and enables architectural rules:
- Naming conventions — a
Facade/Factory/Provider/Configclass must extend the matching Gacela abstract (SuffixExtendsRule). FacadeOnlyDelegatesRule— a Facade only delegates to its Factory instead of holding business logic.FactoryDoesNotCallFacadeRule— a Factory never calls back into a Facade.CrossModuleViaFacadeRule— opt-in (commented out in the shipped config): enforces that modules communicate only through Facades. See Enforcing module boundaries.
Typed pillar accessors
#[ServiceMap] gives a magic accessor a real return type:
#[ServiceMap(method: 'getFacade', className: CheckoutFacade::class)]
final class CheckoutController
{
use ServiceResolverAwareTrait;
public function __invoke(): Response
{
return $this->getFacade()->placeOrder();
}
}PHPStan now checks placeOrder() and every call reached through the Facade. A native @method CheckoutFacade getFacade() annotation is also understood, though the attribute remains the forward-compatible runtime declaration.
Typed provided dependencies
The class-string form of getProvidedDependency() returns the named type:
$clock = $this->getProvidedDependency(Clock::class); // inferred as ClockA plain string key still returns mixed because no type is encoded in the key. Factories themselves may also declare constructor dependencies; pillar construction goes through the container and is autowired:
final class CheckoutFactory extends AbstractFactory
{
public function __construct(private readonly Clock $clock) {}
}Facade interfaces
FacadeInterfaceInSyncRule is enabled by default for a FooFacade that explicitly implements FooFacadeInterface. It reports public Facade methods missing from that interface, preventing consumers typed against the interface from silently seeing a smaller API. Facades with no matching interface are ignored.
Enforcing module boundaries
CrossModuleViaFacadeRule ships commented out in phpstan-gacela.neon. Uncomment it and pass your namespaces to enable it:
services:
-
class: Gacela\PHPStan\Rules\CrossModuleViaFacadeRule
tags: [phpstan.rules.rule]
arguments:
rootNamespace: App\Modules
modulePathSegments: 1
sharedNamespaces:
- App\Modules\SharedrootNamespace(string, required) — your project's module root, e.g.App\Modules.modulePathSegments(int, default1) — how many namespace segments beneath the root identify a single module.sharedNamespaces(list of strings, default[]) — shared kernels exempt from the boundary: references into them are always allowed, and classes inside them aren't checked.
The rule covers construction, static calls, class constants, and static properties. Namespace matching respects boundaries, so App\Modules\Shared does not accidentally exempt App\Modules\SharedFoo.
Dependency cycles and graph review
Use the CLI graph as an architecture gate:
vendor/bin/gacela debug:graph --checkReviewed cycles can be recorded with a required reason:
[
{
"modules": ["App\\Billing", "App\\Invoicing"],
"reason": "Reviewed temporary boundary while extracting a shared kernel"
}
]vendor/bin/gacela debug:graph --check --allowed-cycles=allowed-module-cycles.jsonThe allowlist is self-invalidating: an entry that no longer matches a real cycle fails, preventing stale exceptions from becoming permanent mute buttons.
To make architecture changes visible in a pull request, save JSON on the base branch and compare it on the feature branch:
vendor/bin/gacela debug:graph --format=json > base-graph.json
vendor/bin/gacela debug:graph --compare-to=base-graph.json > graph-diff.mdThe diff is GitHub-flavored Markdown with a Mermaid diagram. An unchanged graph writes nothing and exits successfully; an unreadable or invalid baseline exits non-zero.
Accurate module return types across getFactory(), getConfig() and getProvidedDependency() come from the @template annotations on Gacela's abstract classes plus the @extends on your concrete module classes — independent of this config.
Declare every dynamic accessor
Add #[ServiceMap] or a native @method annotation for each getFacade()-style accessor. Otherwise PHPStan reports an undefined method. A declared return type also enables analysis of every subsequent call.
Psalm
<?xml version="1.0"?>
<psalm
xmlns:xi="http://www.w3.org/2001/XInclude"
xmlns="https://getpsalm.org/schema/config"
>
<projectFiles>
<directory name="src"/>
</projectFiles>
<plugins>
<pluginClass class="Gacela\Psalm\Plugin"/>
</plugins>
<xi:include href="vendor/gacela-project/gacela/psalm-gacela.xml"/>
<issueHandlers>
<InvalidArgument>
<errorLevel type="suppress">
<directory name="src" />
</errorLevel>
</InvalidArgument>
</issueHandlers>
</psalm>The InvalidArgument suppression is required because Gacela resolves concrete types at runtime that Psalm can't infer statically. Suppress inline if you prefer narrower scope:
/** @psalm-suppress InvalidArgument */
return new YourService($this->getConfig());The plugin reads #[ServiceMap(method: 'getFacade', className: MyFacade::class)] and gives the magic accessor its real return type. Without it, psalm-gacela.xml can suppress the undefined magic call but the result becomes mixed, disabling checks on subsequent method calls.
The plugin cannot be delivered by the XInclude because <plugins> belongs elsewhere in the Psalm document, so the explicit block is required.
Troubleshooting
- PHPStan can't find the file: verify the include path resolves relative to your
phpstan.neon. - Psalm ignores the include: ensure
xmlns:xi="http://www.w3.org/2001/XInclude"is declared, thenvendor/bin/psalm --clear-cache.