Skip to content

2.4.0

Latest

Choose a tag to compare

@Chemaclass Chemaclass released this 17 Aug 19:14
2.4.0
c148b7f

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 whatever setEventDispatcher() installed. Nothing was added to AbstractFacade/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:container and validate:config keep describing the application
  • debug:events lists a project's events beside the framework's, marked project, with a source field in --json and a count in the summary. Found under the paths discovery already walks — never vendor/ — by implementing GacelaEventInterface or being named *Event, narrowed by setProjectNamespaces()
  • doctor judges a listener target against that catalog: a listener registered on a class that forgot implements GacelaEventInterface can never fire, which nothing reported before
  • GacelaTestCase::assertEventDispatched() answers from the recording bootstrapGacela() already installs, for a project's events as readily as for Gacela's
  • EventDispatchingCapabilities is 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: Billing announces InvoiceIssuedEvent, Notification handles it, and a test asserts Billing depends on Customer and nothing else. See your own events

PSR-14 interop

  • setEventDispatcher() accepts a Psr\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-dispatcher is 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 (EventDispatchBench unmoved). To answer narrowly, implement Gacela's interface directly
  • Psr14EventDispatcher goes the other way, for a library that expects PSR-14: it returns the event, honours hasListeners(), and does not dispatch one that arrives already stopped. ConfigurableEventDispatcher deliberately does not claim the interface itself — its void return 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 to Shared, Transfer, Dto and Event — so App\Billing\Shared\Invoice is exported with no annotation at all. Segment names matched whole at any depth, never as prefixes: Event leaves EventHandler\ 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:generate marks what it writes: a declared shape is data crossing a boundary by definition
  • debug:module <name> prints a Public API section, and --json a publicApi key, so the surface is readable without opening files
  • DeclaredModuleDependencyRule is deliberately not exempted. Publishing a class says it may be touched without the Facade, not that two modules may be coupled — and debug:graph --check enforces 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-run writes 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 @method type is not a single class name (A|B, ?A, array<A>, self) is left alone, and gacela.serviceMapMissing no longer suggests A|B::class for 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, --listened keeps only watched events, --json emits the same document; says when disableEventListeners() is in effect
  • The event catalog in the docs is checked against the classes on disk in both directions

Cacheable events

  • CacheableHitEvent and CacheableMissEvent make 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 walkedScanned: src, always on doctor and on list:modules, debug:modules, debug:graph when they find nothing
  • Paths skipped — an appModulePaths entry that is not a directory is reported as Not scanned, not a directory: …, and by a new module paths doctor check so --strict can fail on it
  • The filterdoctor names it and how many modules it matched, including none, which used to end in All 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:modules and debug:graph answer 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 final cannot 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.json that debug:graph --check reads, so a boundary holds in the test and in CI or in neither
  • Every failure names the offending edge and the use statement behind it, as file:line
  • In the Gacela\Console namespace because the graph is built by scanning source files, and Gacela\Framework must not depend on Gacela\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.jsonextra.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.json is read once and the resolved list is cached; only packages declaring the key are considered, and vendor/ is never scanned for config files
  • dontDiscover(['vendor/pkg']) opts a package out and dontDiscover(['*']) turns discovery off; opt-outs accumulate across the bootstrap closure and gacela.php
  • A PackageConfigMergedEvent per package makes the merge order observable, debug:container attributes what each package put in the container, and a discovered packages doctor 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 in gacela-project/container 2.1.0, which this release requires (#885, container#195)

  • The four pillars are built from the whole of gacela.php, not from addBinding() alone. The container the class resolver constructs a Facade, Factory, Config or Provider from was seeded with bindings and contextual bindings and nothing else, so loadDefinitions(), addAlias(), addFactory(), addProtected(), addLazy(), tags, handler registries, plugin stacks, extendService() and afterResolving() reached every container except the one a project meets first: an interface declared in a definitions file resolved happily from Gacela::container() and threw DependencyNotFoundException the 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 — BindingRegisteredEvent describes the configuration, which is walked once however many containers apply it, so listener counts and assertBindingRegistered() are unchanged

  • debug:modules --check asks the container that builds the pillars. It read the bindings of Gacela::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. AbstractClassResolver memoized the merged gacela.php on 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 in gacela.php is 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, and doctor's own module paths check reported the list that was not in force

  • doctor no longer reports a #[Provides] method that declares a Container parameter. 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 green doctor --strict

  • A #[Provides] method that resolves the id it declares throws CircularProvidesException naming 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.php at 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

  • doctor and debug:events report the listeners registered in gacela.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" answered no specific listeners registered about 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 a ConfigurableEventDispatcher to hold the listeners and installed it over the top; that class is final, so an application's own dispatcher could never be the one kept, and a gacela.php with 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 its hasListeners() wants it. Changes what an existing setEventDispatcher() user observes — see UPGRADE.md

  • debug:events says when a custom dispatcher is installed, in the text output and as summary.customDispatcher in --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 to clearMethodCacheFor(). Stored keys change shape — see UPGRADE.md

  • addAppConfig('config/*.php') no longer reads the environment files into the base layer. The pattern bin/gacela init scaffolds is globbed literally, so it matched config/app-prod.php and config/app-prod-eu.php too — and matched them as part of the base layer, before the APP_ENV-and-dimensions chain that selects one was applied. A key the base file also set came out right only by accident of glob() 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

  • doctor names every file excluded from a base config path. The rule above reads filenames, and a filename carries no intent: a config/app-extra.php written for some other purpose is excluded too. The new config environment layers check 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 using APP_ENV or a dimension this is what correct looks like

  • @method service resolution reads every form of use statement. A group import, a comma list, or a group wrapped across lines fell back to the caller's namespace — a MissingClassDefinitionException, 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, which gacela.php cannot call; it names GacelaConfig::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. AppModuleCreator discarded whatever resolution threw, so a DependencyNotFoundException out of the pillar's own constructor was reported as "the file is there and nothing can load it — check the namespace declaration", 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.facadeOnlyDelegates no longer recommends methods AbstractFacade does not have. It read "must only delegate to $this->getFactory()/getConfig()/getProvider()/getResolvedType()", and three of those four end in Call to undefined method on 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

  • CrossModuleMethodCallRule no longer reports a call on a Throwable. Catching a neighbour's exception and reading getMessage() is not a boundary crossing; reported, it made every typed catch a finding — 24 of 53 on a 1100-file codebase. new ShopException(...) is still the sibling rule's business
  • The same rule takes an ignoreReceivers list for value objects a project treats as public contracts, matched by is_a() so an interface covers its implementations. ignoreReceivers in the neon service arguments, <ignoreReceiver> under <crossModule> for Psalm
  • registerSpecificListener() matches by inheritance. AbstractGacelaClassResolverEvent::class covers all four resolver events and GacelaEventInterface::class covers every event. Previously a parent class or interface matched nothing — see UPGRADE.md. Applicable listeners are memoized per event class; EventDispatchBench is 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. The docs/events.md recipe for logging resolved classes is one specific listener on the abstract parent
  • The event listeners doctor 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 informational benchmark carries #[RetryThreshold(20)]. phpbench.json sets a suite-wide threshold of 5 and a phpbench retry is unboundedsetRetryLimit() 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 on ScopedCacheBench, 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. The gate group keeps the strict 5, and a test fails if either side drifts