On this page
Reference application Since 2.4
Every feature in Gacela has a fixture built for it. None of them answered whether the features still compose: 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 the whole thing 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 place it belongs.
Read it when you want 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 rather than 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, and 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 actually 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.phpnames it nowhere. 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 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 in config/app-prod.php and nowhere else, so outside production the schema's declared
default answers for it. That demonstrates that the base layer excludes the environment files 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 cannot say 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 behind 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 its generated shapes are regenerated.