Added
Your application's own events
The dispatcher, the guard, the inheritance matching and the tooling were all scoped to the framework's own introspection events. A project can now dispatch its own through the same dispatcher, which is how one module reacts to another without depending on it.
- The dispatcher is an ordinary dependency:
getProvidedDependency(EventDispatcherInterface::class)in a Factory, resolved to whateversetEventDispatcher()installed. Nothing was added toAbstractFacade/AbstractFactory— that is the point. An application binding for the id still wins, and so does a module Provider registering its own - Registered on the module scope rather than the application container, so
debug:containerandvalidate:configkeep describing the application debug:eventslists a project's events beside the framework's, markedproject, with asourcefield in--jsonand a count in the summary. Found under the paths discovery already walks — nevervendor/— by implementingGacelaEventInterfaceor being named*Event, narrowed bysetProjectNamespaces()doctorjudges a listener target against that catalog: a listener registered on a class that forgotimplements GacelaEventInterfacecan never fire, which nothing reported beforeGacelaTestCase::assertEventDispatched()answers from the recordingbootstrapGacela()already installs, for a project's events as readily as for Gacela'sEventDispatchingCapabilitiesis marked@internal: it is how the framework's own pre-injection classes dispatch, and an application injects the dispatcher instead- The reference application shows the module-to-module case:
BillingannouncesInvoiceIssuedEvent,Notificationhandles it, and a test asserts Billing depends onCustomerand nothing else. See your own events
PSR-14 interop
setEventDispatcher()accepts aPsr\EventDispatcher\EventDispatcherInterface— Symfony's, Laravel's, any of them — and wraps it, so a hosted application routes Gacela's events onto the bus it already has.psr/event-dispatcheris now a hard requirement; it is interface-only- PSR-14 cannot be asked what it listens to, so the adapter's
hasListeners()answers true and every dispatch site allocates. The cost falls only on an application that supplied one: with none, the hot-path guard is the single array lookup it always was (EventDispatchBenchunmoved). To answer narrowly, implement Gacela's interface directly Psr14EventDispatchergoes the other way, for a library that expects PSR-14: it returns the event, honourshasListeners(), and does not dispatch one that arrives already stopped.ConfigurableEventDispatcherdeliberately does not claim the interface itself — itsvoidreturn satisfies the signature and breaks the contract
A module can declare its public API
A module's surface is wider than its Facade — DTOs, enums, value objects, events — and until now the only way to say so was an ignore list in phpstan.neon, repeated in psalm.xml, outside the module that owns the class.
#[PublicApi]on a class, interface or enum: the two opt-in cross-module rules stop reporting it. Not inherited, so publishing a base class does not publish everything that extends it- A namespace convention beside it —
publicApiSegments(PHPStan) /<publicApiSegment>(Psalm), defaulting toShared,Transfer,DtoandEvent— soApp\Billing\Shared\Invoiceis exported with no annotation at all. Segment names matched whole at any depth, never as prefixes:EventleavesEventHandler\alone. An explicit empty list turns the convention off - Read by plain reflection in the shared analyser rather than through each host's attribute API, so PHPStan and Psalm agree by construction
dto:generatemarks what it writes: a declared shape is data crossing a boundary by definitiondebug:module <name>prints aPublic APIsection, and--jsonapublicApikey, so the surface is readable without opening filesDeclaredModuleDependencyRuleis deliberately not exempted. Publishing a class says it may be touched without the Facade, not that two modules may be coupled — anddebug:graph --checkenforces the same rules file without ever seeing an attribute- The reference application dropped all four of its
ignoreReceivers/<ignoreReceiver>entries as a result. See what a module exports
migrate:service-map
Writes the #[ServiceMap] attribute for every pillar accessor still resolved from a @method docblock — the resolution 3.0 removes — across the whole project in one run.
--dry-runwrites nothing; an optional argument narrows to paths containing it- Textual and idempotent: adds only the attribute and, when missing, its import; a second run is a no-op
- An accessor whose
@methodtype is not a single class name (A|B,?A,array<A>,self) is left alone, andgacela.serviceMapMissingno longer suggestsA|B::classfor it
debug:events
Lists every event the framework can dispatch, which of them your project listens to, and which fire on the class-resolution hot path.
- The catalog is read off the event classes, so a new event appears without anybody remembering it
- A listener that matches by inheritance is shown against every event it covers, naming the registered target
- An optional argument narrows by class name,
--listenedkeeps only watched events,--jsonemits the same document; says whendisableEventListeners()is in effect - The event catalog in the docs is checked against the classes on disk in both directions
Cacheable events
CacheableHitEventandCacheableMissEventmake a#[Cacheable]hit rate measurable; the miss carries what the callback cost (computeNanoseconds()/computeMilliseconds()) and the TTL. Free when nothing listens
What a run actually looked at
doctor and the module listings now say what discovery walked, in text and as scanned, unscanned, filter and modules in doctor --format=json:
- Paths walked —
Scanned: src, always ondoctorand onlist:modules,debug:modules,debug:graphwhen they find nothing - Paths skipped — an
appModulePathsentry that is not a directory is reported asNot scanned, not a directory: …, and by a newmodule pathsdoctor check so--strictcan fail on it - The filter —
doctornames it and how many modules it matched, including none, which used to end inAll checks passed
Module test slices
GacelaTestCase::bootstrapModule($appRoot, InvoiceFacade::class, doubles: [...]) bootstraps one module with its neighbours replaced — the everyday test of a modular application, which needed a bootstrap plus three override APIs and the question of which one applies to which kind of dependency.
- Narrows module discovery to that module's directory, so
doctor,list:modulesanddebug:graphanswer about one module - Routes each double by what it is: a pillar instance to
swapModule*(), a class or interface to a binding, a lazy service and a resolved-class override, a container id to a service extension — the only reach a bootstrap has into a module's own Provider - Refuses a double that is not an instance of the class it is registered under; a Facade left
finalcannot be doubled by anyone, so replace that module's Factory instead
Module boundaries in PHPUnit
Gacela\Console\Testing\ModuleAssertions is a standalone trait with assertModuleDependsOnlyOn(), assertNoModuleCycles() and assertModuleRulesHold().
- Reads the graph, allowed-cycles file and
module-rules.jsonthatdebug:graph --checkreads, so a boundary holds in the test and in CI or in neither - Every failure names the offending edge and the
usestatement behind it, asfile:line - In the
Gacela\Consolenamespace because the graph is built by scanning source files, andGacela\Frameworkmust not depend onGacela\Console
Reference application
An invoicing SaaS under tests/Feature/ReferenceApp/, wired with every feature at once and run on every pull request. Five modules, a three-layer harness — behaviour, every shipped command, and a guard that fails when a new GacelaConfig method, attribute or command is not used by it — and both analysers over the application with the opt-in architecture rules turned on. See docs/reference-app.md.
Package discovery
A Composer package can contribute to a Gacela application by being installed. It declares its own config file in composer.json — extra.gacela.config, returning the same callable(GacelaConfig) a project's gacela.php returns — and everything it declares is merged before the project's own, so the project always has the last word. Until now a package's listeners, plugin members, #[Provides] providers, suffix types, DTO schemas and doctor checks had to be pasted into every consuming project by hand and kept in sync by hand. See shipping a Gacela package.
vendor/composer/installed.jsonis read once and the resolved list is cached; only packages declaring the key are considered, andvendor/is never scanned for config filesdontDiscover(['vendor/pkg'])opts a package out anddontDiscover(['*'])turns discovery off; opt-outs accumulate across the bootstrap closure andgacela.php- A
PackageConfigMergedEventper package makes the merge order observable,debug:containerattributes what each package put in the container, and adiscovered packagesdoctor check reports a declared config file that is missing or does not return a callable - A broken declaration never stops the boot — it is reported, not thrown
Fixed
-
A service registered with
addLazy(),addFactory(),addAlias()or a definition now answers a constructor parameter naming it. Nested resolution consulted the bindings alone, so the container autowired a second copy of the class instead — and where the class could not be autowired, the first resolution of an unrelated consumer died naming a scalar parameter of a class nobody asked it to build. Fixed ingacela-project/container2.1.0, which this release requires (#885, container#195) -
The four pillars are built from the whole of
gacela.php, not fromaddBinding()alone. The container the class resolver constructs a Facade, Factory, Config or Provider from was seeded with bindings and contextual bindings and nothing else, soloadDefinitions(),addAlias(),addFactory(),addProtected(),addLazy(), tags, handler registries, plugin stacks,extendService()andafterResolving()reached every container except the one a project meets first: an interface declared in a definitions file resolved happily fromGacela::container()and threwDependencyNotFoundExceptionthe moment a Factory asked for it in its constructor. Both containers are now configured through one code path, in one order. The pillar container applies the configuration silently —BindingRegisteredEventdescribes the configuration, which is walked once however many containers apply it, so listener counts andassertBindingRegistered()are unchanged -
debug:modules --checkasks the container that builds the pillars. It read the bindings ofGacela::container(), which is not the one doing the building — so where the two disagree it reported a pillar as buildable that the class resolver cannot build, which is the one failure the flag exists to find -
A second bootstrap in one process resolves against its own bindings.
AbstractClassResolvermemoized the mergedgacela.phpon the instance, and those instances are held by a trait static that no reset reaches — so an application that had used a#[ServiceMap]accessor went on resolving its pillars from the previous application's configuration, reported as "no concrete class was found" for a binding that was plainly there -
setAppModulePaths()written ingacela.phpis honoured. The setup merger carried 24 properties and not that one, so the value survived only from a bootstrap closure: every command walked the whole application root, anddoctor's ownmodule pathscheck reported the list that was not in force -
doctorno longer reports a#[Provides]method that declares aContainerparameter. That is the shape the attribute documents, and the scanner reads the signature and passes the container through — a project writing it could not have a greendoctor --strict -
A
#[Provides]method that resolves the id it declares throwsCircularProvidesExceptionnaming the provider, the method and the id — instead of a stack overflow with a hundred thousand anonymous-closure frames. A loop through a second id names the whole chain -
Event listeners registered in a bootstrap closure and in
gacela.phpat the same time now all fire. The merge replaced one side's generic listeners and the bootstrap memoized the pre-merge dispatcher, so one set was silently dead -
doctoranddebug:eventsreport the listeners registered ingacela.php. The merge put them on the dispatcher without recording them on the setup both commands read, so the two commands whose job is to answer "what is listening" answeredno specific listeners registeredabout a listener that was firing. The merged setup is now the record and the dispatcher is derived from it -
A dispatcher passed to
setEventDispatcher()is no longer discarded when a listener is registered too. The merge built aConfigurableEventDispatcherto hold the listeners and installed it over the top; that class isfinal, so an application's own dispatcher could never be the one kept, and agacela.phpwith any listener in it silently dropped the application's bus after the first couple of events. A supplied dispatcher and the configured listeners are now composed — the listeners run, then the event reaches the supplied dispatcher if itshasListeners()wants it. Changes what an existingsetEventDispatcher()user observes — see UPGRADE.md -
debug:eventssays when a custom dispatcher is installed, in the text output and assummary.customDispatcherin--json: the listener table is what the configuration registered, and a supplied dispatcher carries every event on to a bus the command cannot see into -
A
#[Cacheable]custom key is scoped to the class and method that produced it. Two classes with the same template shared one entry, and custom-keyed entries were invisible toclearMethodCacheFor(). Stored keys change shape — see UPGRADE.md -
addAppConfig('config/*.php')no longer reads the environment files into the base layer. The patternbin/gacela initscaffolds is globbed literally, so it matchedconfig/app-prod.phpandconfig/app-prod-eu.phptoo — and matched them as part of the base layer, before theAPP_ENV-and-dimensions chain that selects one was applied. A key the base file also set came out right only by accident ofglob()ordering; a key set only in an environment file had nothing to overwrite it, so a developer silently read the production value. A match named after another match plus one or more trailing-<segment>parts is now that file's environment layer and is excluded from the base. Anchored on another matched file, so the exclusion can never empty a base layer. Changes what a wildcard config path loads — see UPGRADE.md -
doctornames every file excluded from a base config path. The rule above reads filenames, and a filename carries no intent: aconfig/app-extra.phpwritten for some other purpose is excluded too. The newconfig environment layerscheck reports each one with 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), so the trade is a reported non-load and never a second silent one. A pass, not a warning — for any project usingAPP_ENVor a dimension this is what correct looks like -
@methodservice resolution reads every form ofusestatement. A group import, a comma list, or a group wrapped across lines fell back to the caller's namespace — aMissingClassDefinitionException, or a silent wrong injection -
A stale class-name cache entry heals itself. Rename a Factory, Config or Provider and deploy without clearing the cache dir, and resolution died on a
TypeError; the persisted hit is now validated and re-found when stale, at no warm-path cost -
The "no modules found" hint said discovery goes by filename suffix; it goes by inheritance from
AbstractFacade, and the hint now says so -
The suffix-mismatch doctor check named
SuffixTypesBuilder::addFacade, whichgacela.phpcannot call; it namesGacelaConfig::addSuffixTypeFacade(), and a test fails when any remediation names a method that does not exist -
doctor's unresolved-pillar check no longer blames autoloading for every failure.AppModuleCreatordiscarded whatever resolution threw, so aDependencyNotFoundExceptionout of the pillar's own constructor was reported as "the file is there and nothing can load it — check thenamespacedeclaration", with the namespace, the psr-4 prefix and the class all fine. The thrown class and message are now on the detail line, and the namespace advice is offered only when the class genuinely does not exist -
gacela.facadeOnlyDelegatesno longer recommends methodsAbstractFacadedoes not have. It read "must only delegate to$this->getFactory()/getConfig()/getProvider()/getResolvedType()", and three of those four end inCall to undefined methodon a plain Facade. The tip now names the one root the base class declares and the trait behind each of the others, and the same test that guards the doctor remediations now walks the analyser messages too
Changed
CrossModuleMethodCallRuleno longer reports a call on aThrowable. Catching a neighbour's exception and readinggetMessage()is not a boundary crossing; reported, it made every typedcatcha finding — 24 of 53 on a 1100-file codebase.new ShopException(...)is still the sibling rule's business- The same rule takes an
ignoreReceiverslist for value objects a project treats as public contracts, matched byis_a()so an interface covers its implementations.ignoreReceiversin the neon service arguments,<ignoreReceiver>under<crossModule>for Psalm registerSpecificListener()matches by inheritance.AbstractGacelaClassResolverEvent::classcovers all four resolver events andGacelaEventInterface::classcovers every event. Previously a parent class or interface matched nothing — see UPGRADE.md. Applicable listeners are memoized per event class;EventDispatchBenchis unchanged- The listener callable is typed through the event class (
@template T of GacelaEventInterface), so a listener declared against the wrong event is a static-analysis error. Thedocs/events.mdrecipe for logging resolved classes is one specific listener on the abstract parent - The
event listenersdoctor check no longer warns about an interface or abstract target, since those now fire - Every CI job declares
timeout-minutes(10/20/45), so a stalled runner is a red check to re-run instead of a six-hour block. A test fails the build if a new job forgets one
Internal
- Every
informationalbenchmark carries#[RetryThreshold(20)].phpbench.jsonsets a suite-wide threshold of 5 and a phpbench retry is unbounded —setRetryLimit()exists and nothing calls it — so a group whose numbers are reported and never asserted was paying open-ended CI time to stabilise a reading nothing reads. Measured onScopedCacheBench, one machine, one commit: 34s at threshold 5 against 6.3s at 10. That is what made the performance guard's baseline step run 11–12 minutes and get killed by the job timeout while the gate step beside it passed in 15 seconds — a red check that was never a regression. Thegategroup keeps the strict 5, and a test fails if either side drifts