On this page
Reference application Since 2.4
Every Gacela feature has a fixture built for it. None of them showed whether the features still work together.
Several fixes (a stale cache, listeners lost between gacela.php and the bootstrap closure, unusable remediations)
were each found by probing a scratch project by hand, because no fixture had everything wired at once.
The reference application is that project, inside the repository, run on every pull request. It lives in
tests/Feature/ReferenceApp/ (opens in a new tab) and is
an invoicing SaaS: small enough to read in one sitting, large enough that every capability has a natural place. Read
it to see a feature used next to all the others.
The application
The application root is
tests/Feature/ReferenceApp/Invoicing/ (opens in a new tab),
the directory holding gacela.php, config/ and the five modules.
| Module | What it is, and what it shows |
|---|---|
Customer |
The customer directory. A #[Cacheable] lookup with a per-reference key, and a shape declared with declareDtoSchema() whose generated class is committed. |
Billing |
Issues invoices. Reaches Customer through #[Provides] and getProvidedDependency(), announces InvoiceIssuedEvent through the injected event dispatcher without naming whoever reacts, runs a plugin stack of tax rules and a tagged set of validators, and reads typed configuration against a declared schema. |
Payment |
Takes the money. Declares a fifth resolvable kind, Gateway, dispatches by key through a handler registry, and gets a stricter retry policy through a contextual binding. Its pillars keep the names it arrived with (PaymentApi, PaymentBuilder, PaymentSettings, PaymentDependencyProvider), which is what addSuffixTypeFacade() and its siblings are for. |
Notification |
Delivers, and reacts. It handles Billing's InvoiceIssuedEvent (the subscriber names the event, the publisher names nobody) behind a plugin stack of channels, a header list the application extends with extendService(), and a resolver-event listener registered in gacela.php. |
Reporting |
Reads. Billing's declared shapes through #[Provides] and Customer's names through a #[ServiceMap] accessor, and nothing else: the module the boundary rules are written about. |
Beside them, Shared/ is a shared kernel, not a module: a clock the host supplies, a retry policy, the
invokables that extend the configuration, and the plugins that run at bootstrap. Both analyser configurations name it
as such.
Repositories are in-memory arrays. There is no HTTP and no database: those belong to the host. The point here is the wiring.
The two installed packages
Packages/ holds two Composer packages, declared in a hand-written Invoicing/vendor/composer/installed.json
because nothing here is really installed. They show package discovery both ways:
gacela-fixture/invoice-auditis kept. It adds a delivery channel to the stackNotificationpublishes and a reaction toInvoiceIssuedEvent, andgacela.phpnever names it. The flow test sees itsaudit:receipts beside theemail:ones, anddebug:eventsreports two listeners on the event.gacela-fixture/legacy-numberingis refused withdontDiscover(['gacela-fixture/legacy-numbering']). It would replace the invoice number format, so the expectedACME-INV-01001in the flow test proves its file was never opened.
Configuration
gacela.php: the composition root, and the most useful single file to read.gacela-prod.php: only the differences, read whenAPP_ENV=prod.config/app.php,config/app-prod.php,config/app-prod-eu.php: the base layer and the two that refine it. The second is selected by the declaredAPP_REGIONdimension.services.php: the wiring that is data, read byloadDefinitions().module-rules.json: the boundaries, read bydebug:graph --check --rulesand by both analysers.
payment.default_method is set only in config/app-prod.php, so outside production the schema's declared default
answers for it. This shows that the base layer excludes the environment files that config/*.php also matches.
The harness
Three test classes, each answering a different question:
| Test | Asks |
|---|---|
InvoicingFlowTest (opens in a new tab) |
Does the application work? One flow (register, issue, pay, report) run as a developer runs it and again as production in the EU region. |
InvoicingToolingTest (opens in a new tab) |
Does the toolchain work on it? Every command Gacela ships, run against this application, asserting the exit code and one fact of the output. |
ReferenceAppUsesEveryFeatureTest (opens in a new tab) |
Is it still a reference? Reflects GacelaConfig, the attributes, the traits and the command catalogue, and fails on anything the application does not use and has not explained. |
From a clone of the Gacela repository:
composer test-feature -- --filter=ReferenceApp
composer test-integration -- --filter=ReferenceAppTestThe second runs static analysis over the application at PHPStan level max and Psalm
errorLevel="1", with the shipped rules and the three opt-in ones: cross-module access, declared module
dependencies, and #[ServiceMap] completeness. Their configurations,
phpstan-reference-app.neon (opens in a new tab)
and
psalm-reference-app.xml (opens in a new tab),
are worth copying into a project.
What it does not prove
The module graph is built from use imports at module granularity. So module-rules.json can say that nothing may
depend on Reporting, but not that Reporting may reach only Billing's Facade. That second rule is the
analysers' job, which is why both cross-module rules are enabled.
Nor can a graph say anything about Notification reacting to Billing. An event leaves no import in the module that
dispatched it, so no graph and no rule can tell you who is listening. debug:events
can, and the registration in gacela.php is the one place it is written down.
The upstream page (opens in a new tab) also covers how the application is used to try a new feature before its API is fixed, and how to regenerate its generated shapes.