diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 4657505..0f10401 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -43,6 +43,8 @@ jobs:
- { local: packages/security, split: firefly-security }
- { local: packages/actuator, split: firefly-actuator }
- { local: packages/observability, split: firefly-observability }
+ - { local: packages/admin, split: firefly-admin }
+ - { local: packages/openapi, split: firefly-openapi }
- { local: packages/testing, split: firefly-testing }
- { local: packages/cli, split: firefly-cli }
- { local: packages/firefly, split: firefly-firefly }
diff --git a/.gitignore b/.gitignore
index f19d381..6a1003c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -36,3 +36,8 @@ Thumbs.db
*.log
/coverage/
/.claude/
+
+# Local design-review artifacts, at the repo ROOT only — book/art and docs/assets are tracked, so this
+# must never be a blanket *.png.
+/*.png
+/.playwright-mcp/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ec1c869..8e0a1aa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,340 @@
All notable changes to LaraFly are documented here. This project uses CalVer (`YY.MM.Patch`).
+## [26.09.1] - 2026-09-03
+
+A correctness release that also grew two surfaces. Several headline features were found not to work at all
+outside the compiled boot, and two of the failures were **fail-open** in the security sense — the application
+kept serving, unguarded, with nothing logged; every fix below was reproduced by a failing test first. Alongside
+them, LaraFly gained the two things a framework this shape is expected to have and did not: a browser dashboard
+over the actuator (`firefly/admin`) and an OpenAPI 3.1 document generated from the manifests it already holds
+(`firefly/openapi`). Both now ship with the `firefly/firefly` metapackage — they were outside it, which meant
+a `composer create-project firefly/skeleton` resolved 260 packages and neither of them was among them — and
+neither needs npm or a CDN.
+
+### BREAKING
+
+- **`packages/container` — two non-`#[Primary]` `#[Bean]` methods returning the same type now THROW at
+ registration.** They previously booted, and one of the two beans silently did not exist: a bean name was
+ only ever recorded as `alias($returns, $name)`, and an alias is a pointer to a key rather than a binding of
+ its own, so both names pointed at the single type key, that key held whichever factory registered last, and
+ `getByName('memoryCache')` and `getByName('redisCache')` handed back the identical object. `#[Primary]` could
+ not break the tie because `BeanDescriptor::$primary` was read nowhere in the bean path. **Migration:** give
+ each competing `#[Bean]` method a distinct name and mark exactly one `#[Primary]` — the type key then
+ aliases the primary and every candidate stays individually resolvable. Rejected at registration (where the
+ stack trace still points at the manifest): competing beans that are anonymous, that share a name, that are
+ named after the contested type itself, or that declare more than one `#[Primary]`. A contested type with no
+ `#[Primary]` stays *bound* — to a guard that throws naming every candidate — so `#[ConditionalOnMissingBean]`
+ still sees that a bean of that type exists. See [Dependency Injection](docs/modules/dependency-injection.md).
+
+### Added
+- **`firefly/openapi` — an OpenAPI 3.1 document that cannot drift from the server.** Generated from the
+ artifacts the framework already holds in memory: `RouteManifest` for paths, verbs, declared statuses, route
+ names and the per-parameter binding plan; `ConstraintManifest` for request-body schemas and their `required`
+ lists; `firefly/kernel`'s `ErrorResponse` for the RFC 9457 problem component. There is no annotation dialect
+ and no second description of the API, so there is nothing to keep in sync. `#[NotBlank]`, `#[Size]`,
+ `#[Min]`/`#[Max]`, `#[Email]`, `#[Pattern]`, `#[Percentage]`, `#[Money]` and the rest become JSON Schema
+ keywords; anything JSON Schema cannot state (`after:now`, a Luhn checksum, a PCRE flag ECMA-262 has no syntax
+ for) is recorded under the `x-firefly-constraints` specification extension rather than dropped silently.
+ Nested `#[Valid]` DTOs get their own component, so a self-referential DTO terminates as a `$ref` cycle. Paths,
+ verbs and components are sorted, so a regenerated document diffs cleanly and stays worth committing.
+ `php artisan firefly:openapi` writes it to `--output=` (with a summary line) or **raw** to stdout via
+ Symfony's `OUTPUT_RAW`, so `firefly:openapi | ` gets exactly the document's bytes. Three
+ routes — spec, console, console assets — are mounted natively from a `BootPass` at configurable paths, which
+ an attribute route could not be, and which also keeps the package from documenting itself. See
+ [OpenAPI](docs/modules/openapi.md).
+- **`firefly.openapi.viewer.style` — `swagger` (default) | `builtin` | `cdn`.** The default console is the
+ **official Swagger UI, served from the application's own origin** out of the `swagger-api/swagger-ui` composer
+ package (a hard dependency, so the files are already on disk): byte-for-byte the distribution Swagger
+ publishes — full feature set, deep linking, try-it-out, OAuth2 — with **no CDN request and no npm step**, so
+ it still renders in the air-gapped and strict-CSP deployments where an internal API console is most wanted.
+ Asset serving is a whitelist of seven basenames, each `realpath()`-checked inside the dist directory, behind a
+ route whose `{file}` segment cannot express a traversal; the files are immutable for a pinned version and are
+ sent with a one-year `immutable` cache header and an auto ETag. `builtin` is the hand-written, dependency-free
+ reference (no third-party JavaScript at all) and is also the automatic fallback when the dist is absent, so a
+ missing package never renders a console whose assets 404. `cdn` fetches Swagger UI from `cdn.jsdelivr.net` and
+ is the only style that makes a third-party request at page view. The older boolean `firefly.openapi.viewer.cdn`
+ (default `false`) still forces the CDN page and wins over `style`, so an application that set it keeps the
+ behaviour it configured.
+- **`firefly/admin` — a browser dashboard over the actuator**, the Spring Boot Admin analogue, mounted at
+ `firefly.admin.base-path` (default `/firefly`). Thirteen pages in three operator-shaped groups: overview,
+ health, metrics and HTTP traffic; beans, **bean graph**, conditions, routes and scheduled tasks; environment,
+ config properties, caches and loggers. It reads each `ActuatorEndpoint` **in-process** from `ActuatorRegistry`,
+ deliberately bypassing `ExposureModel` — so it renders pages the JSON surface keeps unexposed while that
+ surface stays secure-by-default — and honours the per-endpoint kill switch
+ (`firefly.management.endpoint.{id}.enabled`), because that key means "off", not "unpublished". A page whose
+ endpoint is unregistered or switched off is hidden from the menu rather than linked; a throwing endpoint
+ degrades its own panel; health details are read from `HealthContributorRegistry` directly rather than through
+ the endpoint's `show-details` disclosure policy. Plain Blade with inline CSS — no npm step, no CDN — and it
+ mounts nothing at all when no view factory is bound. See [Admin Dashboard](docs/modules/admin.md).
+ - **SECURITY — `firefly.admin.enabled` defaults to the value of `app.debug`.** Because the dashboard bypasses
+ exposure, its own URL is the entire boundary in front of `beans`, `env` and `conditions`. An app already
+ serving stack traces is a development environment by definition; an app with debug off must opt in
+ explicitly, and an explicit value wins in both directions. The dashboard ships **no authentication of its
+ own** and has no code edge to `firefly/security`: an application that enables it outside debug **must put
+ the route behind its own auth middleware** (`firefly.security.http.rules` covers `firefly` and `firefly/*`
+ with no code change).
+- **The bean graph (`/firefly/graph`)** — a drawn, layered dependency diagram, not another table.
+ `ComponentScanner` now records each component's constructor class/interface types at **scan** time
+ (`ComponentDescriptor::$dependencies`, declared last with a default so an older compiled manifest still
+ rehydrates), and `BeansCatalog` publishes them, so answering "what depends on what" costs no request-time
+ reflection. `BeanGraph` resolves every dependency through an interface index first — a constructor asks for
+ `EventPublisher`, the bean that satisfies it is `PostgresEventPublisher` — and marks the edge `via` so the
+ indirection is visible rather than silently substituted; layering is a longest-path assignment so arrows read
+ downward; a cycle terminates the walk and is **reported** rather than hanging the page, which turns "the app
+ died at boot with no message" into a named pair of classes. Past 220 nodes the diagram is suppressed in favour
+ of the filterable relations table, and constructor types satisfied by a Laravel binding rather than a bean are
+ listed as "provided outside the container" rather than dropped. See [Bean Graph](docs/modules/bean-graph.md).
+- **`Firefly\Context\Scan\AppScan`** — the seam every capability package uses to resolve its own manifest:
+ compiled artifact, else an in-process scan of `firefly.scan.paths`, else empty. Routes, `#[ControllerAdvice]`
+ handlers, CQRS handlers, event/message listeners, scheduled tasks, validation constraints, method-security
+ rules, `#[ConfigProperties]` DTOs and the `#[Transactional]` manifest all resolve through it, so an uncached
+ application behaves exactly like a cached one. `firefly/cli` joins the `firefly/firefly` metapackage.
+- **`firefly.security.method.strict`** (default `false`) — refuses to boot when no compiled method-security
+ manifest exists, instead of falling back to the scan. The only defence against a build that ships without
+ the compile step.
+- **`firefly.observability.metrics.store` / `.ttl`** — names a cache store, swapping `SimpleMeterRegistry` for
+ the new `CacheMeterRegistry` so counters and timers survive the request that recorded them. `increment()`
+ and `record()` use the store's atomic increment (durations accumulate as integer microseconds, because
+ `increment()` is integer-only and a float read-modify-write drops samples); `setGauge()` is last-writer-wins;
+ `meters()` rehydrates from one index rather than a key scan. Opt-in: on the `array` driver it would be no
+ better than memory.
+- **`#[Controller]`** — the HTML stereotype (Spring's `@Controller` to `#[RestController]`'s
+ `@RestController`), extending `#[RestController]` so `RouteScanner`'s `IS_INSTANCEOF` filter finds it
+ unchanged. `ResponseFactory` now renders `View`/`Renderable`/`Htmlable` and the new `ModelAndView` as
+ `text/html`; arrays and scalars still negotiate to JSON. A bare `string` is deliberately **not** a view name.
+- **`#[ControllerAdvice]`/`#[ExceptionHandler]` are wired for the first time** — `RouteScanner`'s
+ `scanExceptionHandlers()` always existed, but nothing compiled the result, so `ExceptionHandlerRegistry` was
+ empty in every real boot while the docs taught it as working. `firefly:cache` now emits
+ `exception-handlers.php`, and compiles 13 manifests in total.
+- **`packages/config`** — relaxed binding (exact → `snake_case` → `kebab-case` → `SCREAMING_SNAKE_CASE`,
+ acronym-aware) and `#[Profile]` gating for `#[ConfigProperties]` DTOs.
+- **`packages/resilience`** — `circuit-breaker.minimum-number-of-calls` and `.half-open-probe-timeout`,
+ `bulkhead.permit-ttl`, and `firefly.resilience.store.lock-block-timeout` (default `0.5`s) for the mutex wait
+ budget.
+- **`skeleton/config/firefly.php` is now a full configuration reference** — every `firefly.*` key the framework
+ reads, grouped by capability, with its real default and what it does; advanced keys stay commented out at
+ their defaults. This release adds the `firefly.openapi.*` block (including `viewer.style` and the legacy
+ `viewer.cdn`), the `firefly.observability.httpexchanges.*` block (`enabled`, `capacity`, `store`, `ttl`,
+ `include-headers`, `exclude`) and `firefly.management.info.runtime.enabled`, and the file was re-derived
+ mechanically against the keys the source actually reads, in both directions. `skeleton/.env.example` carries the ones that usually vary per environment. The skeleton
+ also gains a `#[Controller]` welcome page (nothing on it hard-coded — real bean/condition counts, the real
+ route table, the real actuator registry) and its first test suite.
+
+- **The OpenAPI document now says what an endpoint RETURNS.** Every success response was `{"type": "object"}`
+ — an object with no members, which a viewer renders as a blank panel and `openapi-generator` turns into
+ `any`. The shape was never unavailable: it is written in the `@return` one line above the method, where
+ PHPStan at level max already checks it against the code on every build, which is what makes reading it safe.
+ `DocType` compiles a PHPDoc type expression into a JSON Schema fragment — array shapes with optional keys,
+ `list`, `array` told apart as array-vs-object, tuples, literal unions, nullable references and the
+ PHPStan pseudo-types (`non-empty-string` → `minLength`, `positive-int` → `minimum`) — and returns *nothing*
+ rather than guessing when it cannot read one. `ResponseSchemaFactory` builds a returned class from its WIRE
+ shape: `jsonSerialize()`'s declared `@return` when there is one, public properties otherwise, because those
+ differ — the skeleton's `Order` publishes a derived `total` that is a method, so reflection alone documented
+ five of the six members the API sends. `#[ApiResponse(type:)]` takes a full expression (`'list'`),
+ resolved through the controller's own imports. Verified by validating live responses member-by-member
+ against the schema the generator wrote for them. See [OpenAPI](docs/modules/openapi.md).
+- **An HTML error page, in the framework's own design.** Any `FireflyException` rendered as `problem+json`
+ regardless of who asked, so a person clicking a stale link in a browser was shown a raw JSON blob; a URL
+ matching no route missed that branch entirely and fell through to Laravel's stock page, so one application
+ produced two unrelated-looking 404s. The page shows the status, the reason, the stable `code` the problem
+ document carries, and — when permitted — the exception, its `previous` chain, the source around the throwing
+ line and a stack trace with *your* frames separated from your dependencies'. `firefly.web.error-page.trace`
+ follows `app.debug` and is enforced where the data is GATHERED: with it off nothing walks the stack, opens a
+ source file or copies the message, so a template mistake cannot leak what was never collected.
+ `firefly.web.error-page.views` hands a status to your own Blade view, bound by the same gate, falling back to
+ the built-in page if it throws. `json-paths` (default `api/*`) forces `problem+json` on your API space
+ whatever the caller's Accept header says. The page itself is built as a string with no container lookups and
+ no view factory, because the failure being explained may *be* the view layer. See
+ [Error Handling](docs/modules/error-handling.md).
+- **The dashboard gained a datasource page, an entity map, and a feature-switch console.** `/firefly/datasource`
+ answers what a config dump cannot: which database (secrets masked), whether it is *up* (one connection probed
+ per load, because a page that opened every configured connection would take the slowest one's timeout to
+ render), what connection reuse actually means in PHP (`ATTR_PERSISTENT`, reported for what it is rather than
+ dressed up as a pool gauge), and what `#[Transactional]` compiled to. `/firefly/data-map` draws the entities
+ and the foreign keys between them. `/firefly/settings` is the only page that CHANGES the application, and has
+ three gates — off by default, writable by a second key, and refused outright in production by a check that is
+ deliberately **not** a configuration key. An optional connection wizard tests an unconfigured connection and
+ hands back a config block; it writes nothing, never inlines a password, is POST-only, and is unavailable in
+ production for the same reason. See [Admin Dashboard](docs/modules/admin.md).
+- **The data browser gained filtering, real pagination, create, and relations you can walk.** Eight
+ comparisons over the columns a resource publishes, always bound — including the `LIKE` ones, where the
+ wildcards go around an escaped value — with an unknown column or operator DROPPED before reaching the driver,
+ so a hand-edited URL cannot probe for column names. Conditions AND with each other and with the search box.
+ Relations are discovered by calling only the methods whose *declared return type* is an Eloquent `Relation`,
+ so a record links to what it references in both directions. `create()` is now offered for an Eloquent-backed
+ resource under the same two switches — the constructor-invariants argument that kept it out was right for a
+ hand-written aggregate and was never true for a model Eloquent builds empty and fills by attribute, which is
+ exactly what `update()` had always done. A `float` column type joins the vocabulary: every non-integer number
+ used to be typed `string`, so a money column read as a string, was offered to a `LIKE` search, and let the
+ editor save `"abc"` into it. See [Data Browser](docs/modules/data-browser.md).
+
+### Changed
+- **`#[Qualifier]` on a parameter is honoured.** It declared `TARGET_PARAMETER` from day one and nothing read
+ it, so `#[Qualifier('redisCache')] Cache $cache` silently received whatever `Cache::class` resolved to. It
+ now rides `ContextualAttribute` — the seam `#[Value]` already used — adding no reflection that was not
+ already happening and leaving the compiled manifest shape untouched.
+- **`#[Bean]` discovery no longer compares stereotype short names.** The gate was `$shortAttr ===
+ 'configuration'`, the one place in the scanner that abandoned `IS_INSTANCEOF`, so `#[Bean]` methods on a
+ user-defined stereotype extending `#[Configuration]` — or on a plain `#[Component]`, Spring's "lite mode" —
+ vanished from the manifest while the class itself was still bound.
+- **`make:firefly-*` output.** `-handler` writes two files (the handler *and* the concrete command/query class
+ its `handle()` takes); `-listener` puts `#[Component]` on the generated class; `-repository` generates a
+ concrete `#[Repository]` extending `EloquentRepository` instead of an unresolvable interface.
+- **`firefly.management.endpoints.web.exposure.exclude` honours `*`**, matching `include` and Spring — the
+ documented kill switch used to expose everything `include` named. An endpoint body renders as `{}` rather
+ than `[]` when empty.
+- The skeleton drops `app/Support/CachedTransactionalConfiguration.php`, the hand-written workaround every
+ application needed while `DataAutoConfiguration` bound an empty `TransactionalManifest`.
+- **Docs, book and README cover the two new packages.** New module guides
+ [OpenAPI](docs/modules/openapi.md), [Admin Dashboard](docs/modules/admin.md) and
+ [Bean Graph](docs/modules/bean-graph.md), wired into `docs/README.md` and `docs/index.md`; the actuator guide
+ gains `/actuator/httpexchanges` + `/actuator/process` and a pointer to the dashboard's access model; the CLI
+ reference gains a table of commands contributed by other packages (`firefly:openapi`, `firefly:eda:consume`,
+ `firefly:outbox:relay`). *LaraFly by Example* is updated in **both** languages: Chapter 11 gains the
+ thirteen-page dashboard table and a full bean-graph section (interface resolution, longest-path layering,
+ cycle reporting, the 220-node ceiling), and Chapter 4A's "CDN flag" section is replaced by the three viewer
+ styles, the whitelisted asset route and the honest cost of `cdn`. Every fenced PHP listing still passes
+ `php -l` (219 per language). Package counts corrected from 25/26 to **27 packages / 28 shippable units** in
+ the README and the publishing runbook.
+- Docs corrected against source throughout: the CLI's cached-vs-uncached boot, the resilience circuit-breaker
+ and bulkhead tables and their state prose, configuration's relaxed binding and profile gating, the web
+ layer's HTML rendering, security's fail-open note and full config table, observability's cross-process
+ registry, and the "compilation lands in M15 — until then bind the manifest yourself" caveat that five module
+ guides still carried.
+- **Documentation for the rebuilt bean graph, the data browser and the OpenAPI schema pipeline.** A new
+ [Data Browser](docs/modules/data-browser.md) guide covers `firefly/admin`'s Django-admin-style view over the
+ data layer: what it discovers (every bean whose scan-time interface list contains `CrudRepository`, read from
+ the compiled `BeansCatalog` rather than a fresh scan, so it can never offer a resource the container never
+ registered), why `firefly.admin.data.enabled` defaults to **`false`** and deliberately does *not* follow
+ `app.debug` or `firefly.admin.enabled` (beans and config are facts about the application; these are facts
+ about its **users**), why writes need `firefly.admin.data.writable` **on top of that** (visibility and custody
+ are different decisions), and why **there is no `create()`** and never will be — an aggregate's invariants live
+ in its constructor, and a form built from a column list can only satisfy them by writing columns the domain
+ model considers impossible. Also documented: the four listing paths and the honest cost of the unpaged one,
+ search bound-never-interpolated, columns derived from the resource rather than from a row, the closed
+ five-value display-type vocabulary and why `decimal` maps to `string`, the identifier/secret write refusals
+ enforced twice, and why no rendered error text is ever an exception message.
+ [Bean Graph](docs/modules/bean-graph.md) is rewritten for the three node kinds — components, `#[Bean]`
+ **products** and `#[ConfigProperties]` DTOs — plus the `injects`/`produces` edge distinction, the identity
+ rule for a contested `#[Bean]` type, and why cycles are reported rather than fatal; the stale "`#[Bean]`
+ factory-method parameters are not drawn" limitation is gone, because they are.
+ [OpenAPI](docs/modules/openapi.md) gains a full "How a request DTO becomes a schema" section: the three
+ sources and why the compiled manifest beats the `#[Constraint]` attributes, why no `additionalProperties:
+ false` is emitted, the complete **attribute → compiled rule → JSON Schema keyword** table mapped from
+ `ConstraintSchemaMapper` (correcting `#[Negative]`, which produces `exclusiveMaximum`, not
+ `exclusiveMinimum`), first-writer-wins, the 3.1 nullable spelling, the one-`pattern`-slot `allOf` fallback,
+ `list` element types read from the constructor docblock via the same `dtos` table `ArgumentResolver`
+ hydrates from, and the narrowed `{}`-vs-`[]` rewrite now that a constructor default genuinely does emit an
+ empty list. The `firefly.openapi.*` config table also gains the five optional Info Object keys that were
+ shipping undocumented — `summary`, `terms-of-service`, `contact.*` and `license.*`, with the rule that
+ `license.name` gates the whole object and `license.identifier` wins over `license.url`, since 3.1 makes the
+ two mutually exclusive. *LaraFly by Example* is extended in **both** languages: Chapter 11 gains a "three
+ kinds of node" section for the graph and a data-browser section placed deliberately beside the access-model
+ argument it contradicts. Every fenced PHP listing still passes `php -l` (220 per language).
+
+### Fixed
+- **`packages/security` — method security failed OPEN.** Both enforcement sites treat "no rule for this
+ method" as ALLOW, so the unconditional empty `SecurityMethodManifest` silently disabled every
+ `#[PreAuthorize]`, `#[Secured]` and `#[RolesAllowed]` in the application. Only `firefly/cli` — then a
+ `require-dev` package absent from the metapackage — ever bound the compiled rules.
+- **`packages/security` — the expression evaluator failed OPEN.** `SecurityExpressionEvaluator` is a singleton
+ whose parse state lives on the instance, and `hasPermission()` calls application code (a user-supplied
+ `PermissionEvaluator`) that may evaluate an expression of its own on that same singleton. The inner call
+ overwrote the outer parse state, so `hasPermission(#id, 'read') and hasRole('ADMIN')` returned **true** for a
+ principal holding no authorities at all. State is now saved and restored in a `finally`.
+- **Boot — the framework only worked in its compiled state.** `firefly:clear` on a freshly created skeleton
+ made the app 404 every route it owned, and no quality gate could see it. Fixed by `AppScan` above.
+- **`packages/data` — `#[Transactional]` was a silent no-op.** Nothing ever loaded the compiled
+ `transactional.php`, so `hasProxyFor()` was always false. `ProxyMaterializer` now makes proxies loadable on
+ both paths (classmap when compiled, generated per-process when not) *before* the manifest is handed out.
+- **`packages/eda-postgres` — with `provider=postgres` no `#[EventListener]` was ever subscribed and outbox
+ rows were ACKed without being delivered**: silent data loss in the headline feature. `firefly:outbox:relay`
+ could not work either, because `downstream_provider` selected no publisher; it now resolves a shipped alias,
+ an `EventPublisher` class-string or a bound container id, validates at command time (not boot), refuses a
+ `PostgresEventPublisher` downstream, and fails loudly instead of exiting successfully when unconfigured.
+- **`packages/eda` — `#[EventListener(order:)]` was discarded at dispatch.** It round-tripped through the
+ manifest and the wiring pass then iterated `all()`; it now iterates `ordered()`.
+- **`packages/resilience` — the CircuitBreaker wedged permanently in HALF_OPEN** when a probe threw a
+ non-recorded exception or its worker died, rejecting 100% of traffic to a healthy dependency until an
+ operator flushed the cache. Probe permits are now expiring leases, an ignored exception explicitly returns
+ its permit, and bulkhead permits (which leaked the same way, and could be driven negative by an unmatched
+ `release()`) are leases too. `state()` reported a stale `open` for a breaker whose wait window had elapsed,
+ so the actuator gauge called a recovering breaker hard-down; it now reports the state `admit()` would decide.
+ The store's mutex WAIT budget is separated from its HOLD TTL, so a `timeout: 0` rate limiter no longer blocks
+ five seconds and then surfaces an unmapped `LockTimeoutException` as a bare HTTP 500 — it raises a 503
+ `RESILIENCE_STORE_LOCK_TIMEOUT`.
+- **`packages/validation`** — `#[Size]` silently flipped from length to numeric semantics beside any constraint
+ emitting `numeric`; a present-but-null value failed every constraint instead of only `@NotNull` (Jakarta
+ semantics); `#[Rules]` lost a custom `ValidationRule`'s constructor arguments on the compiled path, booting
+ `new StartsWith()` where the developer wrote `new StartsWith('ACME')`. Rules now declare their arguments via
+ `Compilable`, or have them recovered from promoted properties at COMPILE time, or are rejected then with an
+ actionable message — never silently stripped at runtime.
+- **`packages/config`** — `ProfileResolver` read raw `getenv()`, which returns `false` under both testbench and
+ `config:cache`, so profiles collapsed to `['default']` exactly where they mattered; `#[Profile]` was
+ declared, exported and documented with zero production readers.
+- **`packages/observability` — `/actuator/metrics` and `/actuator/prometheus` were effectively empty in
+ production.** Under PHP-FPM every request is a fresh process, so a scrape saw only what that scrape's own
+ request recorded — worse than empty, because it reads as data. See `CacheMeterRegistry` above.
+- **`packages/cli`** — `make:firefly-handler` generated code that made the next `firefly:cache` throw and abort
+ the whole compile; `make:firefly-repository` generated an interface nothing could resolve;
+ `make:firefly-listener` generated a class the scanner could not discover. Stub tests now generate from each
+ stub and assert the output is valid PHP *and* discoverable by the relevant scanner.
+
+- **SECURITY — every dashboard write was forgeable from another site.** The admin routes were mounted with no
+ middleware at all, which in Laravel means no session and no `ValidateCsrfToken`, so the `@csrf` field in
+ every dashboard form was decorative: a tokenless `curl -X POST` against `/firefly/loggers` was accepted and
+ changed the log level, and the same held for the data browser's edit and delete and the settings console.
+ A form that renders a CSRF field while the route ignores it is worse than one that renders none. Fixed by
+ attaching the middleware CLASSES rather than the `web` group name — naming the group and guarding on
+ `hasMiddlewareGroup('web')` attached nothing, because the registrar runs before the application defines
+ that group. `skeleton/.env.example` moves to `SESSION_DRIVER=file`: an array session is discarded at the end
+ of the request, so the token could never match and every POST would answer 419. Found by an adversarial
+ review of this branch; Laravel's CSRF middleware skips itself under tests, which is how it survived being
+ written, so the regression test asserts the middleware is attached and the behaviour was proven over real
+ HTTP.
+- **SECURITY — a filter on a masked column was an extraction oracle.** Filtering shipped over every column,
+ which quietly re-opened the channel masking exists to close: a masked column renders as `******`, but a
+ filter over it answers a yes/no question about the real value, and a yes/no question you can ask repeatedly
+ recovers it. Proven against the fixture — twenty-one filtered requests returned `correct horse battery` from
+ a column the listing showed only as asterisks, and `>`/`<` do it faster by binary search. Sensitive columns
+ are now excluded from filtering exactly as they already were from search, in the model and in the control.
+- **Escaping a `LIKE` without an `ESCAPE` clause silently matched nothing.** `contains`/`starts with`
+ backslash-escaped the user's `%` and `_` and then emitted a plain `LIKE ?`, which leaves the driver with no
+ escape character declared — so the backslash was matched literally and a search for `ada_love` returned zero
+ rows against a table holding `ada_lovelace@example.test`. Suppressing the wildcards worked; finding an
+ underscore stopped working, which is the worse half. The predicate now emits an explicit `ESCAPE`, and the
+ search box — which had no escaping at all, so a bare `%` matched every row — goes through the same helper.
+- **`composer create-project firefly/skeleton` shipped neither the dashboard nor the API documentation.**
+ `firefly/admin` and `firefly/openapi` were built, tested, documented and offered by `firefly new --with` while
+ *nothing* required them. The welcome page checks `class_exists()` before linking, so it did not render a
+ broken link — it silently rendered two cards fewer, which is the worse failure because nothing looked wrong.
+ Fixed in the BOM rather than the skeleton, because the asymmetry was the actual bug: for eleven of thirteen
+ capabilities `--with` promotes an already-installed package to an explicit dependency, and for these two it
+ decided whether the code existed at all. `tests/MetapackageCoverageTest.php` holds both ends.
+- **The skeleton's sample REST resource did not persist, and its docblock said it did.** `OrderRepository` kept
+ orders in an array on a singleton and claimed the state survived between requests. PHP shares nothing between
+ requests, so `POST /orders` returned 201 with an id and the very next `GET /orders` reported an empty store —
+ the first thing a new user does. The skeleton's own suite passed throughout, because Laravel reuses ONE
+ application across the requests of a single test. It is now an `EloquentRepository` over two tables — an
+ address is a value and stays an embedded json column, a line is an entity and gets a table, a foreign key and
+ a repository — which also earns the sample its first `#[Transactional]`, gives the data browser something to
+ browse, and gives the entity map an edge to draw. `migrate` joins `post-create-project-cmd`.
+- **`skeleton/config/firefly.php` had drifted from the code it documents.** Three keys the framework reads were
+ undocumented, including `firefly.management.server.address` — half of the management-port feature.
+ `tests/ConfigReferenceTest.php` now checks all 84 keys read through the Config port and fails the build when
+ one is added without a word written about it.
+- **`packages/admin` — the data grid's columns did not line up with their headers.** The listing table carried
+ `class="grid"`, colliding with the layout's own `.grid{display:grid}` utility, so the table became a grid
+ CONTAINER, `thead` and `tbody` computed to `display:block`, and the two row groups sized their columns
+ independently. Invisible in the markup and not findable by reading the CSS — it came out of asking the
+ browser what `display` the element had ended up with.
+- **Tertiary text across the dashboard and the welcome page was below WCAG AA.** `#8d95a1` is 2.8:1 on the
+ dashboard's own background, and it painted table cells, every panel's explanatory note, the uppercase stat
+ labels and the namespace half of every class name — content, not decoration. Now 4.95:1 and 4.76:1 in light,
+ 5.6:1 and 5.3:1 in dark. The brand orange was 3.01:1 as a foreground and is no longer used as text: shapes
+ and text take different oranges.
+
## [26.07.18] - 2026-07-28
### Added
diff --git a/README.md b/README.md
index 9b3a84d..3de9c33 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
-
+
@@ -65,9 +65,9 @@
[*PyFly by Example*](https://github.com/fireflyframework/fireflyframework-pyfly). It builds **Lumen**, the
wallet-and-ledger service in [`samples/lumen/`](samples/lumen/), from an empty directory into a secured,
event-driven, actuator-observed microservice, chapter by chapter — every listing drawn from that real project
-(it boots and its tests pass against this framework version, `26.07.18`).
+(it boots and its tests pass against this framework version, `26.09.1`).
-The book is **complete and bilingual (English + Spanish)**: a quick start, **thirteen chapters** across four
+The book is **complete and bilingual (English + Spanish)**: a quick start, **fourteen chapters** across four
parts — Foundations (DI, config, HTTP), Modelling & Persisting the Domain (repositories, DDD), Coordinating &
Securing the App (CQRS, EDA + transactional outbox, `#[Transactional]`, security), and Observability, Testing
& Delivery (actuator, testing, the CLI + zero-reflection cache) — plus a Laravel→LaraFly cheat-sheet and a
@@ -131,8 +131,9 @@ final class GreetingController
No service-provider boilerplate, no manual route registration: the component scanner finds `GreetingService`
and `GreetingController`, the container autowires `GreetingProperties` into the service by constructor type,
-and the route scanner compiles `#[GetMapping('/greetings/{name}')]` into the route table — all from one
-`php artisan firefly:cache` run. See [Featured Patterns](#featured-patterns) below for the full CQRS, EDA,
+and the route scanner compiles `#[GetMapping('/greetings/{name}')]` into the route table. `php artisan
+firefly:cache` compiles all of that ahead of time for a reflection-free boot; without it the same scan simply
+runs in-process at boot instead, so the app behaves identically either way. See [Featured Patterns](#featured-patterns) below for the full CQRS, EDA,
outbox, and security tour, drawn from the runnable `samples/lumen/` wallet-ledger sample.
LaraFly is not a fork of Laravel and does not hide it — every package layers cleanly on top of
@@ -167,10 +168,11 @@ php artisan firefly:serve
```
`firefly new` wraps `composer create-project firefly/skeleton` (git-init included by default), which already
-wires a sample `#[RestController]`/`#[Service]` pair, sqlite for storage, and a `post-create-project-cmd` hook
-that ran `firefly:cache` for you — so the app is already booting reflection-free. Re-run `firefly:cache`
-yourself any time you add or change a `#[Component]`/`#[RestController]`/`#[CommandHandler]`/etc. class, and
-`firefly:clear` to fall back to the in-process scanner. See [Installation](#installation) for the
+wires a `#[Controller]` welcome page, a sample `#[RestController]`/`#[Service]` pair, sqlite for storage, and a
+`post-create-project-cmd` hook that ran `firefly:cache` for you — so the app is already booting
+reflection-free. Re-run `firefly:cache` any time you add or change a
+`#[Component]`/`#[RestController]`/`#[CommandHandler]`/etc. class; `firefly:clear` drops back to the
+in-process scan, which is slower but functionally identical. See [Installation](#installation) for the
non-global-installer path and [CLI & Project Scaffolding](#cli--project-scaffolding) for the full command
reference.
@@ -330,7 +332,7 @@ and the seam that makes this possible.
Nine showcases below, each an accurate snippet lifted straight from `samples/lumen/` (the wallet-and-ledger
sample) or the framework itself — no invented API. Every attribute and class shown here compiles against the
-shipped `26.07.18` release.
+shipped `26.09.1` release.
### Attribute DI — `#[Service]`
@@ -621,6 +623,42 @@ built-in indicators, `firefly:health`/`firefly:metrics` actuator-over-CLI — se
---
+### Two browser surfaces, neither of which needs npm or a CDN
+
+`firefly/admin` — which arrives with the runtime family — mounts a server-rendered dashboard at `/firefly`
+behind `firefly.admin.enabled` (default: `app.debug`): health, metrics, HTTP traffic, beans, a drawn
+[**bean graph**](docs/modules/bean-graph.md), conditions, routes, scheduled tasks, environment, config
+properties, caches, loggers, and a [**datasource**](docs/modules/admin.md#the-datasource-page) page carrying
+your connections, what PDO does about holding them open, and the compiled `#[Transactional]` contract. It
+reads those endpoints **in-process** rather than over HTTP, so it renders pages the JSON surface deliberately
+keeps unexposed — which makes its own URL the entire security boundary. `firefly.admin.enabled` therefore
+defaults to `app.debug`, and an application that enables it with debug off **must put the route behind its own
+auth middleware**. Read [the access model](docs/modules/admin.md#access-the-whole-security-boundary) first.
+
+The package also ships a Django-admin-style [**data browser**](docs/modules/data-browser.md) over your own
+`CrudRepository` beans — discovered from the compiled bean catalogue, so nothing is registered by hand — with
+filtering, sorting, paging, full CRUD, relations you can walk in both directions, and an
+[**entity map**](docs/modules/admin.md#the-entity-map) that draws the foreign keys between them. It is gated
+*separately*: `firefly.admin.data.enabled` defaults to **`false`** and deliberately does **not** follow
+`app.debug` or `firefly.admin.enabled`, because beans and configuration are facts about the application while
+this page shows facts about its **users**. Writes need `firefly.admin.data.writable` on top of that, and
+creating a record is offered only for an Eloquent-backed resource — for a hand-written aggregate the
+invariants live in its constructor, not in a column list, so that case is refused by name.
+
+One more page **changes** the application rather than describing it: a
+[feature-switch console](docs/modules/admin.md#the-feature-switch-console) with three gates, the third of
+which is not a configuration key — in production every write is refused whatever the other two say.
+
+`composer require firefly/openapi` mounts `GET /openapi.json` and a console at `/openapi`, both generated from
+the same `RouteManifest` the dispatcher dispatches from and the same `ConstraintManifest` the validator
+validates with — no annotation dialect, and nothing that can drift. `php artisan firefly:openapi --output=`
+makes the document a committable build artifact a CI job can diff. The default console is the **official
+Swagger UI, served from your own origin** out of the `swagger-api/swagger-ui` composer package: full feature
+set, no CDN request, no npm step, and it still renders in an air-gapped or strict-CSP deployment. See
+[OpenAPI](docs/modules/openapi.md).
+
+---
+
## Installation
**Requirements:** PHP 8.3+ (8.4 recommended), Composer 2, and an existing (or new) Laravel 13 application —
@@ -641,11 +679,19 @@ composer create-project firefly/skeleton my-app
```
**Adding LaraFly to an existing Laravel app** — `firefly/firefly` is a `type: metapackage` (the Maven BOM
-analogue) that pulls in the whole runtime family with one line, and `firefly/cli` adds the developer console:
+analogue) that pulls in the whole runtime family, developer console included, with one line:
```bash
composer require firefly/firefly
-composer require --dev firefly/cli
+```
+
+The browser dashboard (`firefly/admin`) and the API-documentation package (`firefly/openapi`) come with it. The
+broker adapters (`firefly/eda-rabbitmq`, `firefly/eda-postgres`, `firefly/eda-kafka`) and the test kit
+(`firefly/testing`) stay separate — each binds you to an infrastructure choice or belongs in `require-dev`.
+
+```bash
+composer require firefly/admin # /firefly — the dashboard over the actuator (see its access model first)
+composer require firefly/openapi # /openapi.json + /openapi — a spec that cannot drift, and Swagger UI
```
Point LaraFly at your app's classes and compile it:
@@ -672,10 +718,10 @@ php artisan firefly:serve
| Command | What it does |
|---|---|
-| `firefly:cache` | Compiles the app into `bootstrap/cache/firefly/` — DI, routes, config properties, CQRS handlers, event/message listeners, scheduled tasks, security methods, and `#[Transactional]` proxy classes — for a zero-reflection boot. |
+| `firefly:cache` | Compiles the app into `bootstrap/cache/firefly/` — DI, routes, `#[ControllerAdvice]` exception handlers, validation constraints, config properties, CQRS handlers, event/message listeners, scheduled tasks, security methods, and `#[Transactional]` proxy classes — for a zero-reflection boot. |
| `firefly:clear` | The inverse — deletes `bootstrap/cache/firefly/`; the app falls back to in-process scanning. |
| `firefly:about` / `:routes` / `:health` / `:metrics` | Actuator-over-CLI: render the `info`/`env`/`beans`/`conditions`/`mappings` endpoints, the route table, aggregated health, or the metrics snapshot **in-process**, with no HTTP round-trip. |
-| `make:firefly-controller` / `-service` / `-component` / `-handler` / `-listener` / `-entity` / `-repository` / `-config-properties` | One generator per stereotype — `--query` on `-handler` scaffolds a `#[QueryHandler]`, `--message` on `-listener` scaffolds a `#[MessageListener]`. |
+| `make:firefly-controller` / `-service` / `-component` / `-handler` / `-listener` / `-entity` / `-repository` / `-config-properties` | One generator per stereotype — `--query` on `-handler` scaffolds a `#[QueryHandler]`, `--message` on `-listener` scaffolds a `#[MessageListener]`. `-handler` writes **two** files: the handler and the concrete command/query class its `handle()` takes. |
| `firefly:serve` / `firefly:db` | Thin passthroughs to `artisan serve` (or `octane:start` when Octane is installed) and Laravel's own `migrate`/`db:seed`/`migrate:fresh`. |
```bash
@@ -690,7 +736,7 @@ Full flag reference and generated-file contents: [CLI](docs/cli.md).
## Modules
-25 packages under `packages/*` (plus `firefly/skeleton` at the top level — 26 shippable units in total), each
+27 packages under `packages/*` (plus `firefly/skeleton` at the top level — 28 shippable units in total), each
its own installable Composer package with its own tests and its own [module guide](docs/modules/):
| Group | Module | Package(s) |
@@ -701,8 +747,9 @@ its own installable Composer package with its own tests and its own [module guid
| Foundation | [Application Context](docs/modules/context.md) — the phased boot engine (`ApplicationContext` port) | `firefly/context` |
| Foundation | [Auto-Configuration](docs/modules/starters.md) — `#[Configuration]`/`#[Bean]` starters, conditions | `firefly/autoconfigure` |
| Foundation | [Validation](docs/modules/validation.md) — constraint attributes, `#[Valid]`, structured 422s | `firefly/validation` |
-| Web & API | [Web Layer](docs/modules/web.md) — `#[RestController]` routing, `RouteManifest` | `firefly/web` |
+| Web & API | [Web Layer](docs/modules/web.md) — `#[RestController]`/`#[Controller]` routing, `RouteManifest`, JSON + HTML negotiation | `firefly/web` |
| Web & API | [Web Filters](docs/modules/web-filters.md) — the ordered filter chain onto Laravel middleware | `firefly/web` |
+| Web & API | [OpenAPI](docs/modules/openapi.md) — OpenAPI 3.1 generated from the compiled manifests, `firefly:openapi`, official Swagger UI from your own origin | `firefly/openapi` |
| Resilience & Scheduling | [Resilience](docs/modules/resilience.md) — retry, circuit breaker, bulkhead, timeout, rate limiter, fallback | `firefly/resilience` |
| Resilience & Scheduling | [Scheduling](docs/modules/scheduling.md) — `#[Scheduled]` + distributed locks (cache or Postgres advisory) | `firefly/scheduling`, `firefly/scheduling-postgres` |
| Data & Domain | [Domain (DDD)](docs/modules/domain.md) — `Entity`, `ValueObject`, `AggregateRoot`, `DomainEvent` | `firefly/domain` |
@@ -716,16 +763,21 @@ its own installable Composer package with its own tests and its own [module guid
| Security | [Security](docs/modules/security.md) — principal model, `HttpSecurity`, `#[PreAuthorize]`, JWT/OAuth2 | `firefly/security` |
| Operations | [Actuator](docs/modules/actuator.md) — health, info, env, beans, conditions, mappings | `firefly/actuator` |
| Operations | [Observability](docs/modules/observability.md) — Prometheus-format metrics, `/actuator/prometheus` | `firefly/observability` |
+| Operations | [Admin Dashboard](docs/modules/admin.md) — the browser dashboard over the actuator, read in-process | `firefly/admin` |
+| Operations | [Bean Graph](docs/modules/bean-graph.md) — the dashboard's drawn dependency graph, with cycle reporting | `firefly/admin` |
+| Operations | [Data Browser](docs/modules/data-browser.md) — the dashboard's database browser over `CrudRepository` beans, off by default | `firefly/admin` |
| Testing | [Testing](docs/modules/testing.md) — `FireflyTestCase`, recording doubles, Pest expectations | `firefly/testing` |
| Testing | [Integration Testing](docs/modules/integration-testing.md) — `@group integration`, testcontainers | `firefly/testing` |
| Tooling | [Installer](docs/modules/installer.md) — the global `firefly new` scaffolding tool | `firefly/installer` |
`firefly/firefly` (the runtime metapackage) and `firefly/cli` (the dev-console — see
-[CLI & Project Scaffolding](#cli--project-scaffolding) above) round out the 25 packages; `firefly/skeleton`
-is the 26th unit, a `type: project` create-project template at the top level.
+[CLI & Project Scaffolding](#cli--project-scaffolding) above) round out the 27 packages; `firefly/skeleton`
+is the 28th unit, a `type: project` create-project template at the top level.
---
+## Documentation
+
Start at the **[documentation table of contents](docs/README.md)** — it groups every guide by topic. Highlights:
- [Getting Started](docs/getting-started.md) — the full quickstart, adding LaraFly to an existing app.
@@ -736,7 +788,7 @@ Start at the **[documentation table of contents](docs/README.md)** — it groups
- [Laravel ↔ Spring Boot Comparison](docs/laravel-comparison.md) — concept-by-concept mapping for both audiences.
- [Versioning](docs/versioning.md) · [Contributing](docs/contributing.md) · [Publishing](docs/publishing.md).
- Every [module guide](#modules) above.
-- [*LaraFly by Example*](book/README.md) — the complete bilingual book (13 chapters + appendices, PDF + EPUB).
+- [*LaraFly by Example*](book/README.md) — the complete bilingual book (14 chapters + appendices, PDF + EPUB).
- [`samples/lumen/`](samples/lumen/) — the wallet-and-ledger sample this README's showcases are drawn from;
run its own test suite with `vendor/bin/pest samples/lumen/tests`.
@@ -769,7 +821,7 @@ still ahead, accurately:
today via a plain `#[EventListener]`; a dedicated `firefly/eventsourcing`-style package for event
sourcing/snapshots/projections is future work, as it is in PyFly.
- **Documentation.** The end-to-end [tutorial](docs/tutorial.md) (EN + ES), the *LaraFly by Example*
- [book](book/README.md) (13 chapters + appendices, EN + ES, PDF + EPUB), and a
+ [book](book/README.md) (14 chapters + appendices, EN + ES, PDF + EPUB), and a
[docs table of contents](docs/README.md) all shipped with the documentation-parity milestone.
Deeper guides (more recipes, more diagrams) continue to grow from here.
diff --git a/book/README.md b/book/README.md
index 75e2cd6..121eff8 100644
--- a/book/README.md
+++ b/book/README.md
@@ -93,7 +93,7 @@ book/
src/ # EN manuscript (Markdown)
00-front/ # title/copyright/dedication/preface/conventions
00-quickstart.md # "Build Lumen step by step" quick start
- 01..13-*.md # the thirteen chapters (Parts I-IV)
+ 01..13-*.md # the fourteen chapters, 4A included (Parts I-IV)
90-appendix-a-laravel.md # Laravel -> LaraFly cheat-sheet
94-glossary.md # glossary
src-es/ # ES manuscript, same structure/filenames
@@ -119,7 +119,7 @@ book/
## Manuscript status
The manuscript is **complete** in both languages: a five-file front matter, a
-"Build Lumen step by step" quick start, thirteen chapters across four parts —
+"Build Lumen step by step" quick start, fourteen chapters across four parts —
- **Part I — Foundations**: Why LaraFly, Dependency Injection & Auto-Configuration,
Configuration/Profiles/Secrets, Your First HTTP API
diff --git a/book/book.es.yaml b/book/book.es.yaml
index 5878a3d..3c9a410 100644
--- a/book/book.es.yaml
+++ b/book/book.es.yaml
@@ -19,6 +19,23 @@ labels:
# (Capítulos 7-10: CQRS, EDA/outbox, transacciones, seguridad). Tarea B5: Parte IV
# añadida (Capítulos 11-13: observabilidad, pruebas, CLI/caché) + Apéndices
# (Apéndice A + Glosario). Esto completa el alcance de ~13 capítulos del libro.
+# Tarea B6: firefly/openapi añadido como Capítulo 4A, justo después del capítulo HTTP sobre el
+# que se apoya. El orden de los capítulos sale de ESTA lista, no de los nombres de fichero, y
+# `num` es una etiqueta libre (el Apéndice A ya lo demuestra), así que intercalar un capítulo
+# aquí no exige renumerar los nueve siguientes — y renumerarlos habría invalidado cada
+# referencia cruzada "Capítulo N" de ambos manuscritos y de book/README.md.
+# Tarea B7: ningún capítulo nuevo. El explorador del grafo de beans amplía el Capítulo 11 (es una página
+# de firefly/admin, que ese capítulo ya presenta) y el visor con el Swagger UI oficial amplía el Capítulo
+# 4A (sustituye por completo la antigua sección de "la bandera de CDN", ya que `viewer.style` reemplaza a
+# `viewer.cdn`). Separar cualquiera de los dos en un capítulo propio habría desligado una funcionalidad
+# del paquete al que pertenece, y habría movido una referencia "Capítulo N" sin beneficio para el lector.
+# Tarea B8: de nuevo ningún capítulo nuevo, y por la misma razón. El grafo de beans reconstruido (los
+# productos #[Bean] y los DTOs #[ConfigProperties] son ahora nodos, no solo las clases declarantes) y el
+# nuevo navegador de base de datos son ambos páginas de firefly/admin, que el Capítulo 11 ya presenta, así
+# que ambos amplían ese capítulo en lugar de reclamar uno propio. El navegador de datos en particular
+# PERTENECE junto a la sección del modelo de acceso contra la que argumenta: todo su punto de diseño es que
+# `firefly.admin.data.enabled` deliberadamente NO hereda el valor por defecto `app.debug` que la sección
+# anterior justifica, y separarlos habría eliminado la comparación que hace legible la decisión.
front:
- {id: title, file: 00-front/00-title.md, nav: false}
- {id: copyright, file: 00-front/00-copyright.md, nav: false}
@@ -35,6 +52,7 @@ parts:
- {id: ch02, file: 02-dependency-injection.md, num: 2, title: "Inyección de Dependencias y Auto-Configuración"}
- {id: ch03, file: 03-configuration.md, num: 3, title: "Configuración, Perfiles y Secretos"}
- {id: ch04, file: 04-first-http-api.md, num: 4, title: "Tu Primera API HTTP"}
+ - {id: ch04a, file: 04a-openapi.md, num: "4A", title: "Documentar la API: OpenAPI 3.1 desde los Manifiestos"}
- title: "Parte II — Modelar y Persistir el Dominio"
chapters:
- {id: ch05, file: 05-persistence-repositories.md, num: 5, title: "Persistencia y el Patrón Repositorio"}
diff --git a/book/book.yaml b/book/book.yaml
index 6296670..298c805 100644
--- a/book/book.yaml
+++ b/book/book.yaml
@@ -18,6 +18,23 @@ labels:
# Task B4: Part II completed with Part III opened (Chapters 7-10: CQRS, EDA/outbox, transactions, security).
# Task B5: Part IV added (Chapters 11-13: observability, testing, CLI/cache) + Appendices (Appendix A + Glossary).
# This completes the book's ~13-chapter scope.
+# Task B6: firefly/openapi added as Chapter 4A, immediately after the HTTP chapter it builds on.
+# Chapter order comes from THIS list, not from the file names, and `num` is a free-form label
+# (Appendix A already proves that), so slotting a chapter in here needs no renumbering of the
+# nine chapters that follow — and renumbering them would have invalidated every "Chapter N"
+# cross-reference in both manuscripts plus book/README.md, which is not this task's to edit.
+# Task B7: no new chapter. The bean-graph explorer extends Chapter 11 (it is a page of firefly/admin,
+# which that chapter already introduces) and the official-Swagger-UI viewer extends Chapter 4A (it
+# replaces the old "CDN flag" section outright, since `viewer.style` supersedes `viewer.cdn`). Splitting
+# either into a chapter of its own would have separated a feature from the package it belongs to, and
+# would have moved a "Chapter N" reference for no reader benefit.
+# Task B8: no new chapter, again, and for the same reason. The rebuilt bean graph (#[Bean] products and
+# #[ConfigProperties] DTOs are now nodes, not just declaring classes) and the new database browser are both
+# pages of firefly/admin, which Chapter 11 already introduces, so both extend that chapter rather than
+# claiming one of their own. The data browser in particular BELONGS beside the access-model section it
+# argues against: its whole design point is that `firefly.admin.data.enabled` deliberately does NOT inherit
+# the `app.debug` default the section above it justifies, and separating the two would have removed the
+# comparison that makes the decision legible.
front:
- {id: title, file: 00-front/00-title.md, nav: false}
- {id: copyright, file: 00-front/00-copyright.md, nav: false}
@@ -34,6 +51,7 @@ parts:
- {id: ch02, file: 02-dependency-injection.md, num: 2, title: "Dependency Injection & Auto-Configuration"}
- {id: ch03, file: 03-configuration.md, num: 3, title: "Configuration, Profiles & Secrets"}
- {id: ch04, file: 04-first-http-api.md, num: 4, title: "Your First HTTP API"}
+ - {id: ch04a, file: 04a-openapi.md, num: "4A", title: "Documenting the API: OpenAPI 3.1 from the Manifests"}
- title: "Part II — Modeling & Persisting the Domain"
chapters:
- {id: ch05, file: 05-persistence-repositories.md, num: 5, title: "Persistence & the Repository Pattern"}
diff --git a/book/src-es/04a-openapi.md b/book/src-es/04a-openapi.md
new file mode 100644
index 0000000..41955d6
--- /dev/null
+++ b/book/src-es/04a-openapi.md
@@ -0,0 +1,796 @@
+Parte I — Fundamentos · Capítulo 4A
+
+# Documentar la API: OpenAPI 3.1 desde los Manifiestos {.chtitle}
+
+Al terminar este capítulo sabrás cómo `firefly/openapi` convierte el `RouteManifest` y el `ConstraintManifest` que el Capítulo 4 acaba de construir en un documento OpenAPI 3.1 válido **sin ningún dialecto de anotaciones propio** — cómo `firefly:openapi` convierte ese documento en un artefacto de compilación que un job de CI puede diferenciar, cómo cada `kind` de enlace del plan de ruta compilado se convierte en un Parameter Object o en un Request Body, cómo un `#[NotBlank]` o un `#[Positive]` que ya escribiste se convierte en un `pattern` o en un `exclusiveMinimum`, por qué cada DTO se registra una sola vez y se alcanza por `$ref` en lugar de incrustarse, por qué cada operación lleva el mismo componente de error `problem+json` que produce el renderizador del Capítulo 4, y por qué una ruta HTML `#[Controller]` queda fuera del documento por defecto. Cierra con la consola de navegador que el paquete sirve sobre ese documento — tres estilos, de los cuales el predeterminado es el **Swagger UI oficial servido desde tu propio origen**, sin paso de npm y sin petición a CDN, y de los cuales solo uno habla alguna vez con un tercero.
+
+!!! note "Término nuevo: extensión de especificación"
+ OpenAPI 3.1 permite que un documento lleve miembros cuyos nombres empiezan por `x-`, llamados **extensiones de especificación**. Las herramientas conformes deben ignorarlas, de modo que una extensión puede registrar algo que el vocabulario estándar no sabe expresar sin invalidar el documento. `firefly/openapi` usa exactamente una, `x-firefly-constraints`, y este capítulo muestra qué acaba en ella y por qué nunca se descarta nada en silencio.
+
+---
+
+## No hay nada que anotar
+
+Todas las cadenas de herramientas OpenAPI en PHP anteriores a esta te piden escribir el documento dos veces: una como el código que ejecuta el servidor, y otra como anotaciones, atributos o un fichero YAML que *describen* el código que ejecuta el servidor. Las dos divergen la primera vez que alguien añade un campo con prisa, y la divergencia es invisible — el documento sigue validando, simplemente ya no coincide con el servidor.
+
+`firefly/openapi` no tiene ese modo de fallo disponible, porque no tiene una segunda fuente. El Capítulo 4 terminó con el `RouteManifest`: la tabla compilada de cada `RouteDescriptor` desde la que sirve el dispatcher, que lleva el verbo, la ruta, el estado declarado, el nombre de ruta, la clase y el método del controlador, y el *plan de enlace* por parámetro. El Capítulo 4 también presentó el `ConstraintManifest`: la lista de reglas compilada que `BeanValidator` ejecuta sobre un cuerpo `#[Valid]`. Esos dos artefactos, más el `ErrorResponse` de `firefly/kernel`, son toda la entrada:
+
+```bash
+composer require firefly/openapi
+```
+
+Esa es toda la instalación. Arranca la app y `GET /openapi.json` queda servido; `GET /openapi` renderiza una consola de referencia sobre él. No se anotó nada, y nada puede divergir, porque cada hecho del documento se lee del mismo artefacto compilado que lee el dispatcher.
+
+---
+
+## `firefly:openapi`, y rutas que no son rutas por atributo
+
+El documento también es un fichero que puedes versionar:
+
+```bash
+php artisan firefly:openapi --output=docs/openapi.json # escribe el fichero e imprime una línea de resumen
+php artisan firefly:openapi > openapi.json # escribe el documento en crudo a stdout
+```
+
+El comando existe para que el documento pueda ser un **artefacto de compilación** y no solo un endpoint vivo. Versionar el fichero generado es lo que permite a un job de CI diferenciarlo y hacer fallar un pull request que cambió la API pública sin decirlo, y lo que permite a un repositorio de front-end regenerar su cliente tipado desde una especificación versionada sin arrancar la aplicación PHP en absoluto. Es además la única forma de obtener un documento de un despliegue que mantiene `firefly.openapi.enabled` apagado en producción.
+
+El modo stdout está escrito con la bandera `OUTPUT_RAW` de Symfony, y ese detalle importa más de lo que parece: la salida de consola pasa normalmente por el formateador de Symfony, que trata `<...>` como marcado. Una `description` que mencione un tipo genérico — cualquier cosa que lleve un ángulo y haya llegado al documento desde un valor de configuración — sería o bien engullida o bien lanzaría una excepción ante una etiqueta desconocida. El sentido del modo stdout es canalizar directamente hacia un generador de clientes, así que los bytes deben ser exactamente los bytes del documento. Por eso también la línea de confirmación se imprime **solo** en modo `--output`, donde stdout no es el documento.
+
+Las rutas HTTP — la especificación, la consola y los propios assets de la consola — se montan de forma nativa sobre el `Router` de Illuminate desde un `BootPass`, no se declaran con `#[GetMapping]`:
+
+```php
+final class OpenApiRouteRegistrar implements BootPass
+{
+ public function phase(): BootPhase
+ {
+ return BootPhase::WiringPasses;
+ }
+
+ public function order(): int
+ {
+ return 60;
+ }
+
+ public function run(BootContext $context): void
+ {
+ $container = $context->container;
+
+ /** @var OpenApiProperties $properties */
+ $properties = $container->make(OpenApiProperties::class);
+
+ if (! $properties->enabled) {
+ return;
+ }
+
+ /** @var Router $router */
+ $router = $container->make('router');
+
+ $router->get($properties->specPath, static fn (): mixed => $container->make(OpenApiSpecAction::class)())
+ ->name('firefly.openapi.spec');
+
+ if (! $properties->viewerEnabled) {
+ return;
+ }
+
+ $router->get($properties->viewerPath, static fn (): mixed => $container->make(OpenApiViewerAction::class)())
+ ->name('firefly.openapi.viewer');
+
+ // The official Swagger UI files, served from this application's own origin rather than a CDN. Mounted
+ // under the viewer path so moving the console moves its assets with it, and constrained to a single
+ // path segment so the route cannot express a traversal in the first place — SwaggerAssets whitelists
+ // and realpath-checks the name as well.
+ $router->get($properties->viewerPath.'/assets/{file}', static fn (string $file): mixed => $container->make(SwaggerAssetAction::class)($file))
+ ->where('file', '[A-Za-z0-9._-]+')
+ ->name('firefly.openapi.assets');
+ }
+}
+```
+
+Esta es la misma forma — y el mismo idiom de `BootPass` — que el Capítulo 11 te mostrará para las rutas propias del actuator, y se elige por dos razones independientes. Primera, **una ruta por atributo no puede ser configurable**: `#[GetMapping('/openapi.json')]` hornea su literal dentro de un `RouteDescriptor` compilado en tiempo de `firefly:cache`, de modo que un operador nunca podría mover la especificación de una ruta que choca con una suya, ni podría quitarla de una superficie pública sin borrar el paquete. Segunda, una ruta por atributo entraría en el `RouteManifest` de la aplicación — y el generador lee ese manifiesto, así que **el paquete se documentaría a sí mismo**. Registrarlas de forma nativa deja ambos problemas fuera de la existencia: las rutas salen de la configuración en el arranque, y nunca aparecen en la especificación que sirven. Fíjate en que la ruta de assets se monta *bajo* la ruta del visor, así que mover la consola mueve consigo su hoja de estilos y sus scripts.
+
+`firefly.openapi.enabled` (por defecto `true`) se aplica *aquí*, sobre las rutas, y no sobre los beans — el generador y sus colaboradores son inertes sin rutas, así que cerrar las rutas es todo el interruptor. Apagarlo deja todas esas rutas genuinamente sin enrutar, de modo que devuelven 404 a través de la propia `NotFoundHttpException` del router, que el `ProblemDetailsRenderer` del Capítulo 4 renderiza entonces como un cuerpo de problem-details `404` en condiciones, no como un `500`.
+
+!!! note "Las acciones se resuelven por petición, dentro de la clausura"
+ Construir una `OpenApiSpecAction` en el arranque y capturarla en la ruta congelaría un `OpenApiGenerator` dentro de la ruta durante toda la vida del proceso — exactamente la forma que se rompe bajo Octane, donde el contenedor de una petición posterior es un sandbox distinto. `$container->make(...)` *dentro* de la clausura es la regla, aquí y en cualquier otro paquete del framework que monte una ruta nativa.
+
+---
+
+## Del `RouteManifest` a los Operation Objects
+
+`OpenApiGenerator::generate()` es el punto de entrada — memoiza una única pasada privada `build()` (`$this->document ??= $this->build()`), y `toJson()` la envuelve para un fichero o un cuerpo HTTP. Esa pasada recorre el manifiesto una vez, salta las rutas excluidas, y entrega cada superviviente a `OperationFactory`. Todo lo relativo al resultado es determinista a propósito:
+
+```php
+final class OpenApiGenerator
+{
+ private const array VERB_ORDER = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'];
+
+ private function sortVerbs(array $item): array
+ {
+ $sorted = [];
+
+ foreach (self::VERB_ORDER as $verb) {
+ if (array_key_exists($verb, $item)) {
+ $sorted[$verb] = $item[$verb];
+ unset($item[$verb]);
+ }
+ }
+
+ ksort($item);
+
+ return [...$sorted, ...$item];
+ }
+}
+```
+
+Las rutas se ordenan, los verbos dentro de una ruta se ordenan en el orden canónico en que la propia especificación OpenAPI los enumera, y el registro de esquemas ordena los componentes por nombre. Esto no es pulcritud por la pulcritud. El orden de descubrimiento de rutas depende de la iteración del sistema de ficheros, así que un documento *sin ordenar* se rebarajaría entre máquinas y convertiría cada regeneración en un diff irrevisable — que es exactamente lo que hace que los equipos dejen de versionar el fichero generado, que es lo que hace que se quede obsoleto.
+
+Dentro de una operación, el plan de enlace hace el trabajo de verdad. `OperationFactory` despacha sobre el mismo discriminador `kind` que usa `ArgumentResolver` en tiempo de petición:
+
+```php
+final class OperationFactory
+{
+ public function create(RouteDescriptor $route, string $operationId, SchemaRegistry $registry): array
+ {
+ $parameters = [];
+ $body = null;
+ $files = [];
+ $validated = false;
+ $rejectable = false;
+
+ foreach ($route->bindings as $binding) {
+ $validated = $validated || $binding['valid'];
+
+ switch ($binding['kind']) {
+ case 'path':
+ $parameters[] = $this->parameter($binding, 'path', true);
+ $rejectable = $rejectable || $this->coercible($binding);
+ break;
+ case 'query':
+ $parameters[] = $this->parameter($binding, 'query', $binding['required']);
+ $rejectable = $rejectable || $binding['required'] || $this->coercible($binding);
+ break;
+ case 'header':
+ $parameters[] = $this->parameter($binding, 'header', $binding['required']);
+ $rejectable = $rejectable || $binding['required'] || $this->coercible($binding);
+ break;
+ case 'file':
+ $files[] = $binding;
+ $rejectable = true;
+ break;
+ case 'body':
+ $body = $binding;
+ $rejectable = true;
+ break;
+ }
+ }
+
+ // …the operation's own prose (operationId, summary, description, tags) is assembled here…
+
+ if ($parameters !== []) {
+ $operation['parameters'] = $parameters;
+ }
+
+ if ($body !== null) {
+ $operation['requestBody'] = $this->requestBody($body, $registry);
+ } elseif ($files !== []) {
+ $operation['requestBody'] = $this->multipartBody($files);
+ }
+
+ $operation['responses'] = $this->responses($route, $rejectable, $validated);
+
+ return $operation;
+ }
+}
+```
+
+El único bloque elidido es donde se compone la prosa de cara al humano de la operación; todo lo que se muestra es lo que decide el *plan de enlace*. Leer el plan en lugar de releer la firma del método es lo que hace inequívoco el mapeo. `#[PathVariable]`, `#[QueryParam]` y `#[RequestHeader]` se convierten en Parameter Objects; `#[UploadedFile]` se convierte en una parte `multipart/form-data` tipada como `format: binary`; `#[RequestBody]` se convierte en el Request Body Object; y el sexto `kind`, `service` — el colaborador inyectado por el contenedor sin atributo que presentó el Capítulo 4 — no forma parte del contrato HTTP en absoluto y nunca aparece en el documento. Derivar esa lista de forma independiente tendría que volver a decidir cada uno de esos casos y podría discrepar del dispatcher; leer el plan no puede.
+
+Cuatro decisiones menores rematan una operación:
+
+- **Plantilla de ruta.** La grafía de parámetro opcional de Laravel, `{id?}`, no tiene equivalente en OpenAPI — allí un parámetro de ruta es obligatorio, punto — así que el marcador se elimina y el parámetro queda obligatorio. Emitir dos Path Items en su lugar describiría una superficie de router que no existe y duplicaría cada operación así en un cliente generado.
+- **`operationId`.** El `name` de la ruta cuando lo tiene, y si no, derivado como el nombre corto del controlador menos un `Controller` final, en minúscula inicial, más el nombre del método: `Lumen\Web\WalletController::balance()` se convierte en `walletBalance`. Como `operationId` debe ser único en todo el documento — y un duplicado es el único fallo que hace que la mayoría de generadores de clientes aborten en lugar de degradar — una reclamación repetida se sufija (`walletBalance_2`) en lugar de permitirse que sobrescriba.
+- **`tags`.** El mismo nombre corto, de modo que cada operación de `WalletController` se agrupa bajo "Wallet" en un visor.
+- **`summary`.** Separado del nombre del método: `getBalance` se lee como "Get balance". El nombre de un método es la única etiqueta escrita por un humano que lleva una ruta — el `name` de un `#[Mapping]` es un nombre de ruta de Laravel, no prosa — así que es la fuente honesta.
+
+---
+
+## Cuerpos de petición: un componente por DTO, alcanzado por `$ref`
+
+El `OpenWalletRequest` del Capítulo 4 vuelve a ser el ejemplo conductor:
+
+```php
+final class OpenWalletRequest
+{
+ public function __construct(
+ #[NotBlank]
+ public readonly string $owner_id,
+ #[NotBlank]
+ #[CurrencyCode]
+ public readonly string $currency,
+ ) {}
+}
+```
+
+`POST /api/v1/wallets` lo enlaza con `#[Valid] #[RequestBody]`, y la operación generada lo referencia en lugar de repetirlo:
+
+```json
+{
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/OpenWalletRequest" }
+ }
+ }
+ }
+}
+```
+
+`SchemaRegistry` es lo que hace posible ese `$ref`, y resuelve dos problemas que tiene un generador ingenuo del tipo "incrusta el esquema en cada sitio de uso". El primero es la **duplicación**: un DTO usado por seis operaciones se emitiría seis veces, y cada cliente generado acuñaría seis tipos anónimos estructuralmente idénticos con seis nombres distintos. Registrarlo una vez y referirlo por puntero es lo que hace que `openapi-generator`, `orval` y `kiota` produzcan *un* tipo con nombre por DTO — que es todo el sentido de generar el documento en primer lugar. El segundo es la **recursión**: un DTO con un miembro `#[Valid] ?self $parent` no puede incrustarse en absoluto, porque la expansión no termina. Por eso el registro reserva el nombre del componente *antes* de invocar al constructor del esquema, de modo que una llamada anidada para la misma clase encuentra el nombre ya tomado y devuelve el puntero de inmediato, cerrando el ciclo. Un ciclo de `$ref` es legal y útil en un documento donde una lista de reglas aplanada sería infinita.
+
+Los nombres de componente son el nombre corto de la clase, porque es lo que lee un humano en un visor y lo que un generador convierte en un nombre de tipo. Dos DTOs que compartan nombre corto entre espacios de nombres — `Order\Dto\Address` y `Billing\Dto\Address` — chocarían y uno sobrescribiría al otro en silencio, así que el **segundo** reclamante de un nombre cae a su nombre completamente cualificado con puntos: feo, inequívoco y raro. Gana el primer reclamante, de modo que añadir un segundo `Address` en otro punto de la app nunca renombra el que ya estaba publicado.
+
+Dos propiedades del esquema emitido merecen decirse explícitamente porque ambas son decisiones y no omisiones. La lista de miembros es la **lista de parámetros del constructor en orden de declaración**, porque es exactamente de donde hidrata `ArgumentResolver` — pero una restricción asociada a un miembro sin parámetro de constructor se documenta igualmente, porque `BeanValidator` valida el array decodificado en bruto y por tanto la exige en la entrada de todos modos. Y **nunca se emite `additionalProperties: false`**: el servidor genuinamente ignora las claves fuera de la lista del constructor, así que una especificación que afirmara lo contrario haría que clientes conformes rechazaran peticiones que el servidor habría servido.
+
+---
+
+## Las restricciones se convierten en palabras clave de esquema
+
+Ninguna de las dos mitades de un DTO basta por sí sola. El `ConstraintManifest` conoce el contrato de *validación* pero nada sobre tipos, porque una lista de reglas es, por construcción, sin tipos. El constructor conoce el contrato de *tipos* — `?int`, un enum respaldado, un DTO anidado, un valor por defecto — pero nada sobre las restricciones. Solo con los tipos, `#[NotBlank] string $name` se documentaría como una cadena sin límites; solo con las restricciones, `int $quantity` se documentaría como una cadena. `DtoSchemaFactory` las fusiona, y `ConstraintSchemaMapper` mapea la segunda mitad.
+
+Mapea el **manifiesto compilado**, nunca los atributos `#[Constraint]`. Leer los atributos de vuelta desde el DTO sería la ruta obvia hacia "`#[Email]` → `format: email`", y documentaría un validador que no existe: el manifiesto ya ha aplicado el contrato de nulos Jakarta de `ConstraintScanner`, ya ha expandido `#[Size]` en un *objeto* regla de primera parte en vez de las cadenas polimórficas `min:`/`max:` de Laravel, y ya ha aplanado un nivel de `#[Valid]` en claves con puntos. Generar desde los atributos volvería a derivar todo eso a mano y divergiría la primera vez que cambiara un cuerpo de `toRules()`. Generar desde el manifiesto no puede divergir, porque el manifiesto *es* el contrato.
+
+Este es el mapeo real, restricción a restricción, con las reglas compiladas en la columna central para que veas que el mapeador lee reglas y no atributos:
+
+| Restricción | Compila a | JSON Schema |
+|---|---|---|
+| `#[NotBlank]` | `required`, `string`, `regex:/\S/` | miembro añadido a `required`; `type: string`; `pattern: \S` |
+| `#[NotEmpty]` | `required` | miembro añadido a `required` |
+| `#[NotNull]` | `present` + un objeto regla `NotNull` | miembro añadido a `required` **y** `null` retirado de la unión de tipos |
+| `#[Min(n)]` / `#[Max(n)]` | `numeric`, `gte:n` / `lte:n` | `minimum` / `maximum` |
+| `#[Positive]` / `#[Negative]` | `numeric`, `gt:0` / `lt:0` | `exclusiveMinimum: 0` / `exclusiveMaximum: 0` |
+| `#[PositiveOrZero]` / `#[NegativeOrZero]` | `numeric`, `gte:0` / `lte:0` | `minimum: 0` / `maximum: 0` |
+| `#[Email]` | `email` | `type: string`, `format: email` |
+| `#[Pattern(re)]` | `regex:re` | `pattern`, con los delimitadores PCRE y las banderas inocuas `D`/`u` retiradas |
+| `#[Digits(i, f)]` | `numeric`, un `regex:` anclado | `type: number` + `pattern` |
+| `#[AssertTrue]` / `#[AssertFalse]` | `accepted` / `declined` | `type: boolean` + `const: true` / `const: false` |
+| `#[Future]` / `#[Past]` | `date`, `after:now` / `before:now` | `type: string`, `format: date-time` (la mitad `after:now` se registra, no se mapea) |
+| `#[Size(min, max)]` | un objeto regla `Size` | `minLength`/`maxLength`, o `minItems`/`maxItems` cuando el tipo es un array |
+| `#[UuidValue]` | un objeto regla `Uuid` | `format: uuid` + el `pattern` de UUID |
+| `#[Phone]` | un objeto regla `E164` | `format: phone` + `pattern: ^\+[1-9]\d{1,14}$` |
+| `#[CurrencyCode]` / `#[CountryCode]` | objetos regla `Currency` / `CountryCode` | `format: currency` + `^[A-Z]{3}$` / `format: country-code` + `^[A-Z]{2}$` |
+| `#[LanguageTag]` / `#[PostalCode]` | objetos regla | `format: bcp47` / `format: postal-code`, cada uno con su patrón |
+| `#[Iban]` / `#[Swift]` / `#[Bic]` | objetos regla | `format: iban` / `swift` / `bic`, **patrón retenido** |
+| `#[Cusip]` / `#[Isin]` / `#[Luhn]` / `#[RoutingNumber]` | objetos regla | solo `format`; el dígito de control se registra, no se mapea |
+| `#[Percentage]` | un objeto regla `Percentage` | `type: number`, `minimum: 0`, `maximum: 100` |
+| `#[Money]` | un objeto regla `PositiveMoney` | `type: number`, `exclusiveMinimum: 0`, `multipleOf: 0.01` |
+| `#[DecimalScale(n)]` | un objeto regla `DecimalScale` | `multipleOf` — la escala 2 se convierte en `0.01` |
+
+Tres filas de esa tabla compensan una mirada más detenida.
+
+**El patrón se retiene allí donde la regla normaliza primero.** `Iban` quita espacios y pasa a mayúsculas antes de comparar; `Bic`, `Swift`, `Cusip` e `Isin` pasan a mayúsculas; `Luhn` y `RoutingNumber` quitan separadores. Publicar el patrón posterior a la normalización rechazaría cargas que el servidor acepta encantado, lo cual es peor que subespecificar — así que se emite el `format` y el patrón no.
+
+**`format` es un vocabulario abierto.** En JSON Schema 2020-12 un `format` desconocido es una anotación, no un error. IBAN, BIC, ISIN, CUSIP y E.164 no tienen nombre de formato registrado, así que se emiten unos autodescriptivos (`iban`, `bic`, …) en lugar de nada.
+
+**La nulabilidad se escribe a la manera de 3.1.** OpenAPI 3.1 *es* JSON Schema 2020-12, que eliminó la palabra clave `nullable: true` de 3.0 en favor de una unión de tipos. Un miembro nulable es por tanto `"type": ["string", "null"]`, y un `enum` gana además un miembro `null` — ensanchar solo el tipo dejaría a `null` fallando la enumeración, y la propiedad quedaría, en la práctica, indocumentable como nula.
+
+La única regla que decide toda la fusión es **gana quien escribe primero**, aplicada primero al tipo declarado y luego a las reglas en orden de declaración — el mismo orden en que las aplica el validador:
+
+```php
+final class MapperState
+{
+ public function keyword(string $keyword, mixed $value): void
+ {
+ if (! array_key_exists($keyword, $this->schema)) {
+ $this->schema[$keyword] = $value;
+ }
+ }
+}
+```
+
+Sembrar el tipo PHP declarado *antes* de ver ninguna regla es la razón de que `#[Min(1)] int $quantity` se quede en `type: integer` en vez de ensancharse a `number` por la cadena de regla `numeric` que emite `#[Min]` — un ensanchamiento que documentaría erróneamente `1.5` como aceptable.
+
+Aquí está toda esa tubería sobre un DTO real. Es el propio fixture de pruebas del generador, elegido porque abarca deliberadamente cada ruta de mapeo que tiene el generador — una cadena con límite de longitud, un nulable del contrato de nulos Jakarta, un entero con límites numéricos, un decimal con escala, un enum respaldado, un DTO anidado con `#[Valid]`, un patrón PCRE y un objeto regla:
+
+```php
+final class CreateOrderRequest
+{
+ public function __construct(
+ #[NotBlank] #[Size(max: 64)] public readonly string $reference,
+ #[NotNull] #[Email] public readonly string $email,
+ #[Min(1)] #[Max(999)] public readonly int $quantity,
+ #[Positive] #[DecimalScale(2)] public readonly float $amount,
+ public readonly Currency $currency,
+ #[Valid] public readonly AddressPayload $shipTo,
+ #[Pattern('/^[A-Z]{3}-\d{4}$/D')] public readonly ?string $coupon = null,
+ #[UuidValue] public readonly ?string $idempotencyKey = null,
+ ) {}
+}
+```
+
+…y este es el componente que genera, literalmente:
+
+```json
+{
+ "type": "object",
+ "title": "CreateOrderRequest",
+ "properties": {
+ "reference": { "type": "string", "maxLength": 64, "pattern": "\\S" },
+ "email": { "type": "string", "format": "email" },
+ "quantity": { "type": "integer", "minimum": 1, "maximum": 999 },
+ "amount": { "type": "number", "exclusiveMinimum": 0, "multipleOf": 0.01 },
+ "currency": { "type": "string", "enum": ["EUR", "USD"] },
+ "shipTo": { "$ref": "#/components/schemas/AddressPayload" },
+ "coupon": { "type": ["string", "null"], "pattern": "^[A-Z]{3}-\\d{4}$" },
+ "idempotencyKey": {
+ "type": ["string", "null"],
+ "format": "uuid",
+ "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
+ }
+ },
+ "required": ["reference", "email", "quantity", "amount", "currency", "shipTo"]
+}
+```
+
+Cada miembro de ese objeto es rastreable hasta algo de la clase de arriba: el enum vino de los propios casos del enum respaldado, el `$ref` de `#[Valid]`, las dos uniones nulables de `?string`, el `multipleOf` de `#[DecimalScale(2)]`, y `required` de las reglas de presencia — `coupon` e `idempotencyKey` están ausentes de él porque nada afirma su presencia.
+
+Los DTOs de Lumen muestran la misma maquinaria a menor escala, y uno de ellos muestra un caso que la tabla anterior no puede: una propiedad que lleva **dos** patrones.
+
+```json
+{
+ "owner_id": { "type": "string", "pattern": "\\S" },
+ "currency": {
+ "type": "string",
+ "format": "currency",
+ "allOf": [{ "pattern": "\\S" }, { "pattern": "^[A-Z]{3}$" }]
+ }
+}
+```
+
+`#[NotBlank] #[CurrencyCode] string $currency` produce genuinamente dos patrones — `\S` de la regla de no-blanco y `^[A-Z]{3}$` de la regla de divisa — y JSON Schema tiene exactamente una ranura `pattern` por objeto de esquema. Colapsarlos quedándose con el último descartaría en silencio la garantía de no-blanco, así que varios patrones se convierten en un `allOf` de subesquemas de un patrón cada uno. Un solo patrón sigue siendo un `pattern` liso; el `allOf` aparece únicamente cuando hace falta.
+
+---
+
+## Nada se descarta en silencio
+
+Algunas restricciones no tienen equivalente alguno en JSON Schema, y unas pocas se mapean solo de forma aproximada. Descartarlas calladamente produciría un documento que promete *menos* validación de la que realiza el servidor — un cliente enviaría una carga que la especificación llama válida y recibiría un `422` de vuelta. El caso por defecto del mapeador dice qué ocurre en su lugar:
+
+```php
+final class ConstraintSchemaMapper
+{
+ private function applyObject(MapperState $state, ValidationRule $rule): void
+ {
+ switch (true) {
+ // ... every recognised first-party rule object is matched above.
+ default:
+ // A third-party ValidationRule. Its class name is the only thing about it that is knowable
+ // without executing it, so that is what the extension records.
+ $state->unmapped($rule::class);
+ }
+ }
+}
+```
+
+Todo lo no reconocido — `after:now` y `before:now`, `exists:` y `unique:`, un dígito de control Luhn o CUSIP sin más, un límite que nombra a otro campo (`gte:other_field`) en vez de a un número, y cualquier `ValidationRule` de terceros — se registra bajo `x-firefly-constraints`. También un patrón que el mapeador solo pudo aproximar: `D` y `u` se descartan por ser inocuas de verdad, pero cualquier otra bandera — `i` sobre todo, para la que ECMA-262 no tiene sintaxis en línea dentro de una cadena de patrón — no puede trasladarse, así que el patrón se emite igualmente (es la afirmación verdadera más próxima disponible) *y* la regla original se registra, de modo que un lector puede ver que el patrón publicado es más estricto que el del servidor.
+
+Tres propiedades, tres desenlaces:
+
+```json
+{
+ "deliverAfter": { "type": "string", "format": "date-time", "x-firefly-constraints": ["after:now"] },
+ "slug": { "type": "string", "pattern": "^[a-z]+$", "x-firefly-constraints": ["regex:/^[a-z]+$/i"] },
+ "account": { "type": "string", "format": "iban", "x-firefly-constraints": ["iban:checksum"] }
+}
+```
+
+Las herramientas conformes ignoran las tres extensiones y ven un documento válido. Un humano, o un generador que escribas tú, puede leer la verdad completa.
+
+---
+
+## Respuestas: un componente de problema, y un conjunto de errores derivado
+
+Cada operación termina en el mismo componente de error, y ese componente describe lo que LaraFly *realmente* devuelve, no lo que la RFC 9457 describe en abstracto:
+
+```php
+final class ProblemSchema
+{
+ public const string MEDIA_TYPE = 'application/problem+json';
+
+ public static function response(): array
+ {
+ return [
+ 'description' => 'Error response in RFC 9457 problem+json form.',
+ 'content' => [self::MEDIA_TYPE => ['schema' => ['$ref' => self::REF]]],
+ ];
+ }
+}
+```
+
+La distinción importa porque las dos formas difieren. El `ErrorResponse::toArray()` del Capítulo 4 emite `status`, `title`, `code`, `category` y `severity` incondicionalmente, luego `detail`/`type`/`instance`/`traceId`/`timestamp` solo cuando no son nulos, y luego `errors` solo cuando no está vacío. Así que `code`, `category`, `severity` y `errors` son miembros de Firefly encima de los cinco de la RFC; `type` es opcional aquí, donde la RFC le da un valor por defecto; e `instance` lleva una *ruta* de petición y no una referencia URI. Documentar la forma de la RFC en vez de esta le entregaría a cada cliente generado un decodificador que descarta en silencio los tres miembros sobre los que un llamante realmente ramifica.
+
+Las dos enumeraciones se leen directamente de los propios enums del kernel, de modo que un caso añadido en `firefly/kernel` aparece en la especificación en la siguiente generación sin ninguna edición en `firefly/openapi`:
+
+```json
+{
+ "category": {
+ "type": "string",
+ "enum": ["business", "validation", "security", "infrastructure",
+ "external", "framework", "plugin", "internal"]
+ },
+ "severity": { "type": "string", "enum": ["info", "warning", "error", "critical"] }
+}
+```
+
+Qué estados enumera una operación es algo **derivado, no adivinado**. Compara dos operaciones reales de Lumen. `GET /api/v1/wallets/{id}/balance` toma una variable de ruta `string` y ningún cuerpo:
+
+```json
+{
+ "200": { "description": "Successful response.",
+ "content": { "application/json": { "schema": { "type": "object" } } } },
+ "default": { "$ref": "#/components/responses/Problem" }
+}
+```
+
+`POST /api/v1/wallets/{id}/deposit` toma la misma variable de ruta más un `#[Valid] #[RequestBody] AmountRequest`:
+
+```json
+{
+ "200": { "description": "Successful response.",
+ "content": { "application/json": { "schema": { "type": "object" } } } },
+ "400": { "$ref": "#/components/responses/Problem" },
+ "422": { "$ref": "#/components/responses/Problem" },
+ "default": { "$ref": "#/components/responses/Problem" }
+}
+```
+
+El `400` aparece exactamente cuando la operación tiene algo que `ArgumentResolver` pueda rechazar *antes* de que corra el controlador — un cuerpo que decodificar y enlazar, una subida que validar, una query o cabecera obligatoria que el cliente puede omitir, o un parámetro no-`string` que hay que coercer desde la cadena del cable. Está deliberadamente ausente de `balance`: nada de esa petición puede fallar el enlace, porque un segmento de ruta ausente no casa con la ruta en absoluto, y un `400` documentado que el endpoint no puede producir es ruido que un cliente generado convierte en una rama de error muerta. El `422` aparece exactamente cuando algún enlace lleva `#[Valid]`, porque esa es la única forma de que `BeanValidator` corra y por tanto la única forma de que se lance la `ValidationException` del Capítulo 4. Y `default` cubre todo lo que el propio manejador pueda levantar — un `404` de una `ResourceNotFoundException`, un `409` de una `ConflictException`, un `403` de un `#[PreAuthorize]` denegado — que no puede enumerarse desde el manifiesto de rutas sin leer el cuerpo del controlador, y que de todos modos se renderiza todo a través del mismo `ProblemDetailsRenderer`.
+
+Un `204`, o un retorno `void`/`never`, no obtiene contenido alguno, porque emitir un mapa de contenido para un estado que no lleva cuerpo es exactamente lo que un generador de clientes estricto convierte en un tipo de retorno fantasma. El cuerpo de éxito de todo lo demás es el asunto de la siguiente sección.
+
+---
+
+## El cuerpo de éxito: lo que un endpoint devuelve de verdad
+
+Mira otra vez las dos operaciones de arriba. Ambas respuestas correctas son `{"type": "object"}` — un objeto sin miembros.
+
+Esa era toda respuesta correcta en todo documento que este generador producía, y es la que más importa: un visor la dibuja como un panel en blanco y `openapi-generator` la convierte en `any`, así que la frase más útil que contiene un documento de API — *esto es lo que recibes de vuelta* — era la única que faltaba, en todos los endpoints de todas las aplicaciones.
+
+El razonamiento había sido que un `@return array{...}` es texto de comentario que ninguna otra parte del framework trata como vinculante. Eso ya había dejado de ser cierto. `RouteScanner` lee `@param list` para compilar la tabla desde la que `ArgumentResolver` **hidrata**, así que una expresión de tipo en un docblock es exactamente igual de vinculante que un tipo declarado a la *entrada*. Y hay un argumento aún más fuerte: **PHPStan en nivel max ya comprueba estas expresiones contra el código en cada build**, que es lo que hace seguro leerlas. Un `@return` desactualizado es una puerta que falla, no una mentira silenciosa.
+
+Así que el cuerpo de éxito sale ahora de tres fuentes, de la más específica a la menos:
+
+```php
+final class OrderController
+{
+ /**
+ * Una página de pedidos.
+ *
+ * @return array{page: positive-int, size: positive-int, total: int, items: list}
+ */
+ #[GetMapping]
+ public function index(int $page, int $size): array
+ {
+ return $this->orders->page($page, $size);
+ }
+}
+```
+
+```json
+{
+ "type": "object",
+ "properties": {
+ "page": { "type": "integer", "minimum": 1 },
+ "size": { "type": "integer", "minimum": 1 },
+ "total": { "type": "integer" },
+ "items": { "type": "array", "items": { "$ref": "#/components/schemas/Order" } }
+ },
+ "required": ["page", "size", "total", "items"],
+ "additionalProperties": false
+}
+```
+
+1. La **expresión de tipo de `@return`** — el único sitio donde un `array` de PHP puede decir qué lleva dentro. La prosa escrita después del tipo se convierte en la `description` de la respuesta, que es la única descripción de respuesta que alguien escribe de verdad.
+2. El **tipo de retorno declarado** — una clase se convierte en un `$ref` a un componente, un enum respaldado en su conjunto de valores, un escalar en sí mismo.
+3. **Ninguno de los dos** — `type: object`, el comportamiento anterior, conservado como *reserva* para un retorno `array` sin nada dicho sobre él. Un `@return array` se analiza sin problema y no significa nada, así que se trata como que no dice nada en lugar de dejar que suprima lo que el tipo declarado sí sabía.
+
+### Un analizador, no otra expresión regular
+
+El paquete ya tenía dos regex para la única forma que manejaba, `list` y `X[]`, y no se pueden extender al resto. `array{items: list>}` necesita `<>` y `{}` balanceados y una coma que solo separa en el nivel exterior. Eso es una gramática, y una gramática quiere un analizador — unas doscientas líneas de descenso recursivo en `DocType`, frente a las cuatro dependencias transitivas que `phpstan/phpdoc-parser` metería en toda aplicación que instale este paquete.
+
+| Escrito | Se convierte en |
+|---|---|
+| `list`, `Order[]`, `array` | `type: array` con `items: {$ref: Order}` |
+| `array` | `type: object` con `additionalProperties` |
+| `array{a: int, b?: string}` | un objeto, `required: [a]`, `additionalProperties: false` |
+| `array{a: int, ...}` | lo mismo, abierto — el `...` es lo único que lo levanta |
+| `array{int, string}` | `prefixItems` — una tupla |
+| `'draft'\|'sent'` | `type: string` con `enum` |
+| `?Order` | `anyOf: [{$ref}, {type: null}]` |
+| `non-empty-string`, `positive-int` | `minLength: 1`, `minimum: 1` |
+| `never`, `callable`, un nombre irresoluble | **nada** — quien llama vuelve a lo que ya sabía |
+
+Un `?` sobre una **clave** de shape significa «puede estar ausente» y se convierte en `required`; un `?` sobre el **valor** significa «puede ser null». Confundir ambos documenta como obligatorio un miembro omitible. Los nombres de clase se resuelven a través de los imports del fichero donde se escribió la expresión, porque la reflexión no expone las sentencias `use` de un fichero — sin eso solo funcionarían los nombres completamente cualificados, que es la única forma que nadie escribe.
+
+### Una clase devuelta se construye desde su forma de cable
+
+`ResponseSchemaFactory` no es `DtoSchemaFactory`, y la diferencia es el asunto. Esa fábrica deriva los miembros del **constructor** y las reglas del `ConstraintManifest` — las dos fuentes correctas para una carga que el servidor enlaza y valida, y las dos equivocadas a la salida. Una respuesta nunca se valida, y sus miembros son lo que `json_encode` emite.
+
+Que PHP escribe de dos maneras. Una clase que implementa `JsonSerializable` se serializa como lo que `jsonSerialize()` **devuelve**; todo lo demás como sus **propiedades públicas**. El `App\Orders\Order` del esqueleto es el caso que decide el diseño:
+
+```php
+final readonly class Order implements JsonSerializable
+{
+ /** @return array{id: int|null, customer: string, email: string, shipTo: Address, lines: list, total: float} */
+ public function jsonSerialize(): array
+ {
+ return [/* … */ 'total' => $this->total()];
+ }
+}
+```
+
+`total` es un **método** derivado, no una propiedad. Reflejar solo las propiedades publicaría cinco de los seis miembros que la API envía de verdad. El array shape enuncia los seis, PHPStan lo comprueba contra el método, y el generador lo lee — borra la anotación y `total` desaparece en silencio del documento mientras la API sigue enviándolo.
+
+Una forma declarada solo gana cuando dice algo: `@return array` en `jsonSerialize()` significa «un objeto, miembros desconocidos», que es estrictamente menos que la lista de propiedades que habría suprimido, así que se ignora en favor de la reflexión.
+
+Una regla se invierte a la salida. **Ser nullable no es ser opcional aquí.** Un miembro de respuesta está presente o ausente, y `?int $id` está siempre *presente* y a veces es null — así que los miembros de respuesta siguen siendo `required` y los nullables ensanchan su tipo. La regla del lado de la petición habría dicho a todo cliente que esperase una ausencia que nunca ocurre.
+
+### `#[ApiResponse]` también acepta una expresión de tipo
+
+```php
+final class ConsignmentController
+{
+ #[PostMapping(status: 201)]
+ #[ApiResponse(status: 409, description: 'Esa referencia ya existe.', type: Consignment::class)]
+ #[ApiResponse(status: 202, description: 'Aceptado para reservar más tarde.', type: 'list')]
+ public function book(): array
+ {
+ return $this->consignments->book();
+ }
+}
+```
+
+`type` es una expresión completa, no solo el nombre de una clase o de un escalar, y un nombre corto se resuelve a través de los propios imports del controlador.
+
+---
+
+## Las rutas HTML `#[Controller]` no son operaciones
+
+El Capítulo 4 presentó `#[RestController]` junto a su hermano HTML `#[Controller]`, y solo el primero es una API JSON. El generador honra esa distinción por defecto:
+
+```php
+final class OpenApiGenerator
+{
+ private function excluded(RouteDescriptor $route): bool
+ {
+ if ($route->html && ! $this->properties->includeHtml) {
+ return true;
+ }
+
+ foreach ($this->properties->excludePathPrefixes as $prefix) {
+ if (str_starts_with($route->path, $prefix)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
+```
+
+Una ruta `#[Controller]` renderiza una página. Forma parte de la superficie HTTP de la aplicación, pero no es una operación JSON, y describirla como `application/json` haría que un generador emitiera un cliente tipado para una respuesta que es una página web — la propia página de bienvenida del framework estaba en la especificación exactamente así antes de que existiera esta regla. Pon `firefly.openapi.include-html` a `true` y la ruta se documenta igualmente, pero con honestidad: la operación se produce entonces con contenido `text/html` y un esquema `type: string`, no con un esquema JSON que sería una mentira sobre la que un generador de clientes actuaría fielmente.
+
+La segunda mitad de ese método es el instrumento romo para todo lo demás: `firefly.openapi.exclude` es un CSV de prefijos de ruta — `'/internal,/admin'` — para rutas que son JSON pero no son la API pública de nadie.
+
+---
+
+## El visor: tres estilos, y solo uno de ellos llama fuera
+
+`GET /openapi` renderiza una consola de navegador sobre el documento. Cuál de ellas la decide `firefly.openapi.viewer.style`, y la elección es una decisión de cadena de suministro disfrazada de preferencia:
+
+| `style` | Se sirve desde | ¿Petición a un tercero en cada visita? |
+|---|---|---|
+| `swagger` **(por defecto)** | tu propio origen, desde el paquete de composer `swagger-api/swagger-ui` | **no** |
+| `builtin` | en línea en la respuesta | **no** |
+| `cdn` | `cdn.jsdelivr.net` | **sí, en cada visita** |
+
+Un valor no reconocido cae de vuelta a `swagger` en lugar de renderizar una página en blanco — una errata en un fichero de configuración no debería costarte nada.
+
+### Por qué el valor por defecto es el Swagger UI oficial, desde tu propio origen
+
+Todos los visores de estantería — Swagger UI, Redoc, Elements — son aplicaciones JavaScript empaquetadas, y durante años eso dejaba a un paquete PHP exactamente dos opciones. Incrustar un bundle minificado de varios megabytes en el historial git del propio paquete, de modo que cada clon de cada proyecto dependiente lo pague para siempre y el framework quede atado a un tren de releases que no puede parchear sin publicar una versión propia. O traerlo de una CDN en cada visita, lo que es una dependencia de cadena de suministro y una cuestión de protección de datos, y lo que *no renderiza en absoluto* en los entornos aislados y de CSP estricta donde más se quiere una consola de API interna.
+
+Hay una tercera opción, y este paquete la toma. `swagger-api/swagger-ui` publica su `dist` en Packagist bajo Apache-2.0, así que **composer** puede traerlo y fijarlo — es un `require` duro de `firefly/openapi`, de modo que los ficheros ya están en disco en `vendor/` cuando llegas por primera vez a la ruta — y `SwaggerAssetAction` sirve esos ficheros desde el propio origen de la aplicación:
+
+```php
+final class SwaggerAssetAction
+{
+ public function __construct(private readonly SwaggerAssets $assets) {}
+
+ public function __invoke(string $file): SymfonyResponse
+ {
+ $path = $this->assets->path($file);
+ $type = $this->assets->contentType($file);
+
+ if ($path === null || $type === null) {
+ return new Response('Not Found', 404, ['Content-Type' => 'text/plain; charset=UTF-8']);
+ }
+
+ $response = new BinaryFileResponse($path, 200, ['Content-Type' => $type]);
+ $response->setPublic();
+ $response->setMaxAge(31536000);
+ $response->setImmutable();
+ $response->setAutoEtag();
+
+ return $response;
+ }
+}
+```
+
+Obtienes la consola byte a byte tal y como la publica Swagger — el conjunto completo de funciones, deep linking, try-it-out, el popup de redirección OAuth2 — sin petición a CDN, sin paso de npm, y sin nada en el historial de este repositorio que un `composer update` no pueda sustituir. La cabecera de caché larga es segura precisamente porque los bytes son inmutables para una versión fijada: composer solo los cambia cuando cambia la versión fijada, y el ETag cambia con ellos.
+
+!!! note "El path traversal se defiende con una lista blanca, no con un saneador"
+ Solo siete nombres de fichero son servibles siquiera — `swagger-ui.css`, `swagger-ui-bundle.js`, `swagger-ui-standalone-preset.js`, `oauth2-redirect.html`, dos favicons e `index.css` — y cada ruta resuelta se comprueba con `realpath()` para estar dentro del directorio dist. La propia ruta restringe `{file}` a `[A-Za-z0-9._-]+`, así que ni siquiera puede *expresar* un traversal. Cotejar contra una lista fija en lugar de limpiar la entrada es la elección deliberada: una lista blanca no puede vencerse con un truco de codificación que se le escapó a un saneador. Cualquier otra cosa es un 404 `text/plain` simple — no problem+json, porque quien llama aquí es un navegador pidiendo una hoja de estilos, no un cliente de API.
+
+El directorio dist se localiza preguntando a **los metadatos de versiones instaladas del propio Composer** por la raíz del paquete, en lugar de subir directorios desde `__DIR__`. La profundidad de `src/Web/` a `vendor/` difiere entre un paquete instalado (`vendor/firefly/openapi/src/Web`) y este monorepo (`packages/openapi/src/Web`), así que un recorrido relativo funcionaría exactamente en uno de los dos; ese recorrido se conserva solo como respaldo para un runtime cuyo autoloader no pueda responder.
+
+Y si la distribución falta de verdad — un `vendor/` recortado, un phar, un runtime sin composer — `render()` cae de vuelta en lugar de servir una página cuyos assets dan 404:
+
+```php
+final class ViewerPage
+{
+ public function render(string $specUrl, string $style, string $assetBase = ''): string
+ {
+ return match (true) {
+ $style === 'cdn' => $this->swaggerUiFromCdn($specUrl),
+ // Falling back rather than rendering a broken page: `swagger` is the DEFAULT, so an
+ // application that has not installed swagger-api/swagger-ui would otherwise get a console
+ // referencing assets that 404. The built-in reference needs nothing and is always available.
+ $style === 'swagger' && $this->assets->available() => $this->swaggerUi($specUrl, $assetBase),
+ default => $this->builtIn($specUrl),
+ };
+ }
+}
+```
+
+### Para qué sirve `builtin`
+
+Una referencia escrita a mano y sin dependencias: un script en línea, unos cientos de bytes de CSS, un solo `fetch` a la ruta de la especificación, y una paleta que sigue a `prefers-color-scheme`. Hace las dos cosas que quien lee realmente necesita de una especificación generada y que el JSON en crudo no le da — agrupa las operaciones por etiqueta con verbos y rutas visibles de un vistazo, y **resuelve los punteros `$ref` en el cliente**, de modo que quien lee ve los miembros de un DTO y no un puntero a `#/components/schemas`. Try-it-out, flujos OAuth y ejemplos de código están deliberadamente ausentes; para eso está `swagger`.
+
+Elígelo cuando la regla del despliegue sea *nada de JavaScript de terceros en la respuesta*, y no meramente *nada de hosts de terceros*.
+
+!!! note "Por qué esa página es un nowdoc"
+ El visor integrado incrusta una aplicación JavaScript, y un **heredoc** de PHP interpola variables. Cada `$ref`, `$schema` y `$1` de ese script se leía por tanto como una variable PHP — `$ref` se convertía calladamente en la cadena vacía, y la resolución de `$ref`, que es todo el sentido de la página, dejaba de funcionar. Un nowdoc toma el script literalmente y las dos sustituciones reales se hacen explícitamente después. Es la clase de bug que no produce ningún error en ninguna parte: la página renderiza, y sencillamente muestra punteros en lugar de esquemas.
+
+### Lo que cuesta `cdn`
+
+```php
+// config/firefly.php
+return [
+ 'openapi' => ['viewer' => ['style' => 'cdn']],
+];
+```
+
+Cada visita carga entonces Swagger UI desde `cdn.jsdelivr.net`. La versión está fijada exactamente, y **no se declara ningún hash de Subresource Integrity** — deliberadamente: un hash que el framework no puede verificar en el momento de publicar es teatro de seguridad, y uno equivocado simplemente rompería la página.
+
+Sopesa el intercambio con honestidad. A cambio de una petición a un tercero en cada visita, de una Content-Security-Policy que tiene que permitir ese host, y de una consola que no renderiza nada en un despliegue aislado, obtienes… el mismo Swagger UI que `swagger` ya te servía desde tu propio origen. El estilo se mantiene porque es lo que muestran la mayoría de los tutoriales, y porque algunas organizaciones prefieren genuinamente que sus bytes vengan de una caché en la que ya confían — no porque sea el mejor valor por defecto.
+
+!!! warning "`viewer.cdn` sigue ganando sobre `viewer.style`"
+ `firefly.openapi.viewer.cdn` (por defecto `false`) es la grafía booleana antigua de esta opción, de antes de que `style` existiera. Sigue **forzando** la página de CDN y anula a `style`, de modo que una aplicación que la fijó conserva el comportamiento que configuró en lugar de que una actualización del framework la mueva calladamente a otra consola. Prefiere `style` en configuración nueva; borra `cdn` cuando lo adoptes.
+
+El visor trae la especificación desde la ruta hermana en lugar de tener el documento incrustado en la página, de modo que una especificación regenerada aparece con un simple refresco del navegador, y de modo que las dos rutas puedan exponerse de forma independiente — un despliegue puede muy bien querer el documento legible por máquina público y la consola apagada, o al revés. La URL de la especificación se resuelve a través del `UrlGenerator` en lugar de concatenarse, porque una app montada bajo un subdirectorio o detrás de `APP_URL` obtendría si no un enlace que da 404 desde cualquier página que no sea la raíz, y un visor cuya única llamada de red es incorrecta es un visor que no muestra nada en absoluto.
+
+---
+
+## Configuración, y asegurar la superficie
+
+Todo lo que el documento y sus rutas necesitan vive bajo una sola clave de configuración:
+
+```php
+ [
+ 'enabled' => true, // master gate: off means every route is genuinely unrouted
+ 'path' => '/openapi.json', // spec route
+ 'viewer' => [
+ 'enabled' => true,
+ 'path' => '/openapi', // assets are mounted under {path}/assets/{file}
+ 'style' => 'swagger', // swagger (default) | builtin | cdn — see above
+ ],
+ 'title' => 'Lumen Wallet API',
+ 'version' => '1.0.0',
+ 'description' => '',
+ 'servers' => ['https://api.example.test'], // bare URLs or OpenAPI Server Objects
+ 'exclude' => '/internal,/admin', // CSV of path prefixes to leave out
+ 'include-html' => false, // document #[Controller] routes as text/html
+ ],
+];
+```
+
+`servers` acepta las dos grafías que usa un fichero de configuración real — una lista de URLs sueltas, y la propia forma de objeto de OpenAPI con una `description` — y una entrada que no sea ninguna de las dos se descarta en lugar de emitirse, porque un Server Object sin `url` es inválido bajo el esquema 3.1 y envenenaría un documento por lo demás correcto.
+
+Un despliegue público se asegura como cualquier otra ruta. Las reglas de `HttpSecurity` del Capítulo 10 — más adelante, en la Parte III — cubren las rutas de especificación y visor sin ningún borde de código, porque `HttpSecurityFilter` es un middleware global y corre para las rutas registradas nativamente exactamente igual que corre para tus controladores:
+
+```php
+ [
+ 'enabled' => true,
+ 'http' => [
+ 'enabled' => true,
+ 'rules' => [
+ ['pattern' => 'openapi', 'access' => 'hasRole:DEVELOPER'],
+ ['pattern' => 'openapi/*', 'access' => 'hasRole:DEVELOPER'],
+ ['pattern' => 'openapi.json', 'access' => 'hasRole:DEVELOPER'],
+ ],
+ ],
+ ],
+];
+```
+
+Fíjate en los tres patrones. `openapi` a secas no casa con `openapi/assets/swagger-ui.css`, y `openapi.json` es un literal aparte — un conjunto de reglas que cubre la consola pero no sus assets produce una página autenticada cuya hoja de estilos responde 401, que es peor resultado que cualquiera de los dos extremos.
+
+La alternativa, para un despliegue que no quiere ninguna superficie de documentación en producción, es `enabled => false` más un paso `firefly:openapi --output=` en CI.
+
+---
+
+## Reemplazar una pieza de la tubería
+
+Cada colaborador del paquete — `OpenApiProperties`, `ConstraintSchemaMapper`, `DtoSchemaFactory`, `OperationFactory`, `OpenApiGenerator` y `ViewerPage` — es un `#[Bean]` tras un `#[ConditionalOnMissingBean]`, el mecanismo del Capítulo 2. Enseñarle al generador tu propia `ValidationRule` es por tanto una `#[Configuration]` corta en tu aplicación y nunca un fork:
+
+```php
+#[Configuration]
+final class ApiDocsConfiguration
+{
+ #[Bean]
+ public function constraintSchemaMapper(): ConstraintSchemaMapper
+ {
+ return new HouseConstraintSchemaMapper; // teaches the generator your own ValidationRules
+ }
+}
+```
+
+!!! note "La reflexión de aquí es real, y no está en el camino de petición"
+ `DtoSchemaFactory` refleja el constructor de un DTO para conocer los tipos de sus propiedades, lo que parece una violación de la regla que el Capítulo 13 enunciará por completo: nada en el camino de petición cacheado refleja. No lo es. Esa reflexión corre cuando `firefly:openapi` genera un fichero, o ante un impacto en la ruta de la especificación — cuyo resultado el generador memoiza durante toda la vida del proceso — y nunca mientras se despacha una petición de la aplicación. Es la misma categoría de trabajo que `RouteScanner` y `ConstraintScanner`, que ambos reflejan solo en tiempo de compilación. La alternativa, enseñar a `RouteScanner` a emitir tipos por propiedad dentro de cada `RouteDescriptor`, se rechazó porque haría crecer el manifiesto de rutas compilado de *todas* las apps en beneficio de un único paquete opcional.
+
+!!! laravel "Paridad con Laravel"
+ Laravel de serie no trae soporte OpenAPI. Las respuestas habituales son un paquete de terceros gobernado por su propio dialecto de anotaciones (los bloques `@OA\` de `zircote/swagger-php`, o las clases de atributos de `vyuldashev/laravel-openapi`) o un fichero YAML mantenido a mano — ambos son una *segunda* descripción de la API, situada junto a las rutas y los FormRequests que realmente la imponen, y ambos divergen. `firefly/openapi` no tiene dialecto que aprender porque no tiene segunda descripción: lee el mismo `RouteManifest` desde el que despacha el dispatcher y el mismo `ConstraintManifest` con el que valida el validador. El análogo más cercano fuera de PHP es springdoc-openapi, y lo más parecido dentro del propio Laravel es `php artisan route:list` — exacto por la misma razón, e incapaz por la misma razón de decirte nada sobre un cuerpo de petición.
+
+---
+
+## Lo que aprendiste {.recap}
+
+| Concepto | Qué hace |
+|---|---|
+| `OpenApiGenerator` | Ensambla un documento OpenAPI 3.1 desde `RouteManifest` + `ConstraintManifest`; memoizado por instancia, ordenado de forma determinista para que las regeneraciones diferencien limpiamente |
+| `firefly:openapi` | Escribe el documento en `--output=` o en crudo a stdout, convirtiendo la especificación en un artefacto versionable que un job de CI puede diferenciar |
+| `OpenApiRouteRegistrar` | Monta `/openapi.json`, `/openapi` y `/openapi/assets/{file}` nativamente desde un `BootPass` — rutas configurables que una ruta por atributo nunca habría podido tener, y sin autodocumentación |
+| `OperationFactory` | Mapea cada `kind` de enlace — `path`/`query`/`header`/`file`/`body` — a su forma OpenAPI; los enlaces `service` nunca aparecen |
+| `SchemaRegistry` | Un componente por DTO, alcanzado por `$ref`: sin tipos generados duplicados, y un nombre reservado cierra un ciclo recursivo de `$ref` |
+| `DtoSchemaFactory` | Fusiona los tipos declarados del constructor con las restricciones compiladas; no emite `additionalProperties: false`, porque el servidor ignora las claves extra |
+| `ConstraintSchemaMapper` | Mapea la lista de reglas compilada — no los atributos — a palabras clave; gana quien escribe primero, así que `#[Min(1)] int` se queda en `integer` |
+| `x-firefly-constraints` | Registra lo que JSON Schema no sabe enunciar (`after:now`, un dígito de control, un PCRE con banderas, una regla de terceros) en lugar de descartarlo |
+| `ProblemSchema` | La única respuesta compartida `application/problem+json`; documenta `code`/`category`/`severity`/`errors` de Firefly, con los enums leídos de los propios casos del kernel |
+| Conjunto de errores derivado | `400` solo cuando algo es rechazable antes de que corra el controlador, `422` solo bajo `#[Valid]`, `default` siempre |
+| `DocType` | Compila una expresión de tipo PHPDoc a un fragmento de JSON Schema — shapes, genéricos, tuplas, uniones de literales, pseudo-tipos de PHPStan — y devuelve *nada* en lugar de adivinar cuando no puede leer una |
+| `ResponseSchemaFactory` | Construye una clase devuelta desde su forma de CABLE: el `@return` declarado de `jsonSerialize()` cuando lo hay, las propiedades públicas si no. Los miembros de respuesta siguen siendo `required` y los nullables ensanchan su tipo |
+| `#[ApiResponse(type:)]` | Una expresión de tipo completa (`'list'`), resuelta con los propios imports del controlador |
+| `$route->html` | Las rutas HTML `#[Controller]` quedan excluidas por defecto; `firefly.openapi.include-html` las documenta como `text/html`, nunca como JSON |
+| `firefly.openapi.viewer.style` | `swagger` (por defecto) \| `builtin` \| `cdn`. Solo `cdn` hace una petición a un tercero en cada visita; un valor no reconocido cae de vuelta a `swagger` |
+| `SwaggerAssets` | Sirve el Swagger UI OFICIAL desde tu propio origen, desde el paquete de composer `swagger-api/swagger-ui` — siete nombres de fichero en lista blanca, cada uno comprobado con `realpath()` dentro del directorio dist |
+| `ViewerPage::render()` | Cae de vuelta a `builtin` cuando falta la dist de Swagger, en lugar de renderizar una consola cuyos assets dan 404 |
+
+---
+
+## Ponlo en práctica {.exercises}
+
+1. **Genera el documento de Lumen y léelo.** Ejecuta `php artisan firefly:openapi --output=openapi.json` en el sample y abre `/openapi` en un navegador. Busca `walletBalance` y confirma que no tiene respuesta `400`; luego busca `walletDeposit` y confirma que tiene tanto un `400` como un `422` — y convéncete, con las reglas de este capítulo, de por qué difieren.
+2. **Convierte la especificación en una puerta de CI.** Versiona el fichero generado y añade un job que lo regenere y ejecute `git diff --exit-code` sobre él. Cambia un DTO — añade un `#[Size(max: 32)]` a `OpenWalletRequest::$owner_id` — y observa al job fallar con un diff que nombra la palabra clave de esquema exacta que cambió.
+3. **Demuestra que la consola por defecto no hace ninguna petición saliente.** Abre `/openapi` en el sample con el panel de red del navegador grabando, y confirma que todas las peticiones son del mismo origen: la página, `openapi/assets/swagger-ui.css`, los dos bundles y `openapi.json`. Luego pon `firefly.openapi.viewer.style` a `cdn`, recarga, y observa aparecer `cdn.jsdelivr.net` en ese mismo panel — esa petición es toda la diferencia, y es lo que una CSP estricta o un host aislado bloquearía.
+4. **Borra una anotación y mira cómo el documento pierde un miembro.** En el esqueleto, quita el `@return array{...}` de `App\Orders\Order::jsonSerialize()`, regenera, y encuentra `total` ausente del esquema `Order` mientras `GET /orders/1` lo sigue devolviendo. Vuelve a ponerlo, luego cambia `total: float` por `total: string` y ejecuta PHPStan: la puerta que mantiene honesto al documento es la que falla.
+5. **Observa a una restricción caer hasta la extensión.** Añade `#[Future]` a una propiedad `string` de un DTO de petición, regenera, y encuentra el array `x-firefly-constraints` de la propiedad llevando `after:now` junto a un `format: date-time` perfectamente corriente. Luego añade `#[Pattern('/^[a-z]+$/i')]` a otra propiedad y compara: el patrón *sí* se publica, y la regla original se registra a su lado porque la bandera `i` no pudo sobrevivir a la traducción.
diff --git a/book/src-es/11-observability-actuator.md b/book/src-es/11-observability-actuator.md
index 0c4185f..7077de2 100644
--- a/book/src-es/11-observability-actuator.md
+++ b/book/src-es/11-observability-actuator.md
@@ -2,7 +2,7 @@
# Observabilidad: Salud, Métricas y el Actuator {.chtitle}
-Al terminar este capítulo conocerás el SPI `HealthIndicator` de `firefly/actuator` y los indicadores integrados `Ping`/`DiskSpace`/`Db`, cómo `HealthEndpoint` los agrega en una única respuesta `/actuator/health` — y cómo un **grupo** de sondeo (el mecanismo que hay detrás de "liveness" y "readiness") no es más que un subconjunto de indicadores con nombre y configurado, cómo toda la superficie de gestión está **sin exponer por defecto** de modo que un endpoint olvidado falla cerrado como un 404 en lugar de una fuga de información, y el `MeterRegistry` en PHP puro de `firefly/observability`, su exportador Prometheus a prueba de locale, y el truco exacto de precedencia `#[Order(500)]` — el mismo que el Capítulo 10 te mostró para la seguridad — que permite a `MeterRegistryCqrsMetrics` reemplazar el `NoOpCqrsMetrics` del bus de CQRS sin ningún cambio de código en `firefly/cqrs`.
+Al terminar este capítulo conocerás el SPI `HealthIndicator` de `firefly/actuator` y los indicadores integrados `Ping`/`DiskSpace`/`Db`, cómo `HealthEndpoint` los agrega en una única respuesta `/actuator/health` — y cómo un **grupo** de sondeo (el mecanismo que hay detrás de "liveness" y "readiness") no es más que un subconjunto de indicadores con nombre y configurado, cómo toda la superficie de gestión está **sin exponer por defecto** de modo que un endpoint olvidado falla cerrado como un 404 en lugar de una fuga de información, y el `MeterRegistry` en PHP puro de `firefly/observability`, su exportador Prometheus a prueba de locale, y el truco exacto de precedencia `#[Order(500)]` — el mismo que el Capítulo 10 te mostró para la seguridad — que permite a `MeterRegistryCqrsMetrics` reemplazar el `NoOpCqrsMetrics` del bus de CQRS sin ningún cambio de código en `firefly/cqrs`. El capítulo cierra con `firefly/admin`, el panel de administración renderizado en el servidor sobre esos mismos endpoints — un **grafo de beans** dibujado que resuelve cada dependencia de constructor a través de la interfaz por la que está cableada y reporta los ciclos con los que, si no, un arranque moriría sin mensaje. Lee esos endpoints **en proceso**, de modo que renderiza páginas que la superficie JSON deliberadamente mantiene sin exponer, lo que convierte a su propia URL en toda la frontera de seguridad y a su valor por defecto (`app.debug`) en la línea más importante del paquete.
!!! note "Término nuevo: actuator"
Un **actuator** es un endpoint de gestión que informa sobre el *proceso en ejecución en sí* — si está sano, con qué arrancó, cuán rápidas son sus peticiones — en lugar de sobre el dominio de negocio al que sirve el proceso. El término y la forma provienen ambos de Spring Boot Actuator; `firefly/actuator` es un análogo PHP de primera parte y con pocas dependencias: endpoints de framework montados directamente sobre el mismo `Router` de Illuminate que usan tus propios controladores, no un proceso de administración separado.
@@ -378,7 +378,7 @@ return [
El propio `composer.json` de `firefly/actuator` no tiene ninguna dependencia de `firefly/security` en absoluto — asegurar el actuator de esta manera es **pura configuración**, apoyándose en el mismo DSL de URL de denegar-por-defecto que el Capítulo 10 ya te enseñó, sin ningún mecanismo nuevo que aprender.
!!! warning "Cualquier otro endpoint de gestión es un endpoint independiente, no una sub-clave de `/info`"
- `/actuator/info` genuinamente tiene exactamente dos fragmentos — `app` (de `AppInfoContributor`, leído desde `firefly.management.info.app.*`) y `build` (de `BuildInfoContributor`, que lee un archivo JSON en `firefly.management.info.build.path`). `env`, `beans`, `conditions`, `mappings`, `loggers` y `scheduledtasks` son cada uno su **propio** `ActuatorEndpoint`, montado en su propio `/actuator/{id}` — no anidado bajo `/info`. `firefly:about` (Capítulo 13) renderiza varios de estos juntos en el terminal, lo cual es una comodidad de ese único comando, no una prueba de que compartan una ruta.
+ `/actuator/info` genuinamente tiene exactamente tres fragmentos — `runtime` (de `RuntimeInfoContributor`, registrado por defecto, que es la razón por la que una aplicación recién generada ya responde algo: versión/SAPI/OPcache de PHP, versión de Laravel, versión de LaraFly, memoria actual y pico; desactívalo con `firefly.management.info.runtime.enabled = false`), `app` (de `AppInfoContributor`, leído desde `firefly.management.info.app.*`) y `build` (de `BuildInfoContributor`, que lee un archivo JSON en `firefly.management.info.build.path`). `env`, `beans`, `conditions`, `mappings`, `loggers`, `scheduledtasks`, `configprops` y `caches` son cada uno su **propio** `ActuatorEndpoint`, montado en su propio `/actuator/{id}` — no anidado bajo `/info`. `firefly:about` (Capítulo 13) renderiza varios de estos juntos en el terminal, lo cual es una comodidad de ese único comando, no una prueba de que compartan una ruta.
---
@@ -648,8 +648,355 @@ Cada bean de observabilidad se apoya en la **misma** propiedad, `firefly.observa
Por último, un puerto `Tracer` remata el paquete — una abstracción mínima de span `trace(string $name, callable $callback): mixed`, entregada hoy solo como `NoOpTracer` (simplemente ejecuta la clausura), escrita contra la interfaz de modo que un adaptador respaldado por OpenTelemetry pueda caer en su lugar más tarde con cero cambios en los sitios de llamada — la idéntica forma "puerto ahora, adaptador después" que ya has visto para el propio `CqrsMetrics`.
+---
+
+## El panel de administración: `firefly/admin`
+
+Todo lo visto hasta aquí en este capítulo es JSON, y JSON es la forma correcta para un balanceador de carga, una sonda de Kubernetes y un scraper de Prometheus. No es la forma correcta para una persona a las 3 de la madrugada que quiere saber si este proceso compiló sus manifiestos, qué auto-configuración se echó atrás, y a qué resolvió realmente `firefly.data.*`. `firefly/admin` es el paquete para esa persona: un panel de administración renderizado en el servidor sobre esos mismos endpoints del actuator, en el espíritu de Spring Boot Admin. Llega con `firefly/firefly` como el resto de la familia, así que un proyecto del esqueleto ya lo tiene — y, igual que con `firefly/actuator`, tenerlo instalado no es lo mismo que tenerlo activado. Añádelo directamente solo si cogiste los paquetes por separado:
+
+```bash
+composer require firefly/admin
+```
+
+Después abre `/firefly`. No hay paso de npm en la instalación ni CDN en tiempo de petición — las vistas son Blade puro con CSS en línea y tipografías del sistema, porque un paquete de Composer no puede dar por hecho que npm se ha ejecutado, y un panel que necesita la red es inútil precisamente en los entornos aislados donde más quieres mirar uno.
+
+La mayoría de las páginas son una vista sobre la carga útil de un endpoint; cuatro leen el contenedor en su lugar. El menú las agrupa como piensa un operador y no como están dispuestos los paquetes — qué está haciendo ahora mismo, qué cableó en el arranque, cuáles son sus datos, y cómo está configurado — porque una lista plana de diecisiete enlaces es peor menú que cuatro cortas:
+
+| Grupo | Página | Lee | Responde |
+|---|---|---|---|
+| Runtime | Resumen | varios | ¿Está sano, qué está haciendo, y qué cableó? |
+| Runtime | Salud | `health` | Cada indicador que registró este proceso, con su propio estado y detalles |
+| Runtime | Métricas | `metrics` | Contadores, cronómetros y medidores, con sus mediciones actuales |
+| Runtime | Tráfico HTTP | `httpexchanges` | Las peticiones más recientes que sirvió esta aplicación |
+| Cableado | Beans | `beans` | Cada bean que registró el contenedor, con el estereotipo que lo declaró |
+| Cableado | Grafo de beans | `beans` | Cómo dependen tus beans unos de otros, resueltos a través de las interfaces por las que están cableados |
+| Cableado | Condiciones | `conditions` | Qué auto-configuraciones se aplicaron, y cuáles se echaron atrás porque aportaste la tuya |
+| Cableado | Rutas | `mappings` | La tabla de rutas compilada desde la que sirve el dispatcher |
+| Cableado | Programadas | `scheduledtasks` | Métodos registrados por `#[Scheduled]`, con el cron o intervalo que los dispara |
+| Configuración | Entorno | `env` | La configuración `firefly.*` resuelta, con los secretos enmascarados |
+| Configuración | Propiedades de configuración | `configprops` | Cada DTO `#[ConfigProperties]` que enlazó la aplicación, con los valores que resolvió |
+| Configuración | Cachés | `caches` | Los almacenes de caché que tiene configurados esta aplicación |
+| Configuración | Loggers | `loggers` | Canales de log y sus niveles, con un control para cambiar uno |
+
+Una página cuyo endpoint no está registrado en *este* proceso — o está apagado — queda **oculta del menú** en lugar de ofrecerse como un enlace que aterriza en una disculpa. Eso importa porque los endpoints del actuator son condicionales: `metrics` desaparece cuando `firefly.observability.metrics.enabled` es falso, y varios otros existen solo si está instalado el paquete que los aporta. El menú tiene que construirse a partir de lo que este proceso registró de verdad, y así se hace.
+
+---
+
+### Lee los endpoints en proceso, no por HTTP
+
+El panel sostiene el `ActuatorRegistry` e invoca cada bean `ActuatorEndpoint` directamente:
+
+```php
+final readonly class AdminEndpointReader
+{
+ public function __construct(
+ private ActuatorRegistry $registry,
+ private Config $config,
+ private ?Container $container = null,
+ ) {}
+
+ /** The endpoint ids that are registered AND not switched off, in registration order. */
+ public function available(): array
+ {
+ $ids = [];
+ foreach ($this->registry->all() as $id => $endpoint) {
+ if ($endpoint->enabled() && $this->config->bool("firefly.management.endpoint.{$id}.enabled", true)) {
+ $ids[] = $id;
+ }
+ }
+
+ return $ids;
+ }
+
+ public function read(string $id, array $subPath = [], array $query = []): ?array
+ {
+ $endpoint = $this->registry->get($id);
+ if ($endpoint === null || ! $this->has($id)) {
+ return null;
+ }
+
+ try {
+ $response = $endpoint->handle(new EndpointRequest('GET', $subPath, $query));
+ } catch (Throwable) {
+ return null;
+ }
+
+ return $response === null || is_string($response->body) ? null : $response->body;
+ }
+}
+```
+
+Fíjate en lo que **no** hay en ese método: ninguna mención a `ExposureModel`. Eso es lo más importante que hay que entender de este paquete, y es deliberado. `firefly.management.endpoints.web.exposure.include` vale `health,info` por defecto, así que traer `/actuator/beans` o `/actuator/env` por HTTP daría un 404 — como toda la primera mitad de este capítulo insistió en que debía ser. El panel no necesita nada de eso. Renderiza lo que el proceso ya sabe, en proceso, de modo que **te muestra páginas que la superficie HTTP deliberadamente no expone**, y la superficie JSON sigue siendo segura por defecto. Exponer `beans`, `conditions` y `env` a cualquier llamante anónimo solo para que un navegador pudiera leerlos sería exactamente el intercambio equivocado.
+
+El interruptor de apagado por endpoint *sí* se honra, y la asimetría es el quid: `firefly.management.endpoint.{id}.enabled` significa "este endpoint está apagado", que es una afirmación sobre el endpoint en sí; `exposure.include` significa "este endpoint no está publicado", que es una afirmación sobre la superficie HTTP. El panel no es la superficie HTTP.
+
+Un endpoint que lanza se captura y se reporta como `null` en vez de dejar que se lleve la página por delante — la misma disciplina a prueba de fallos que `HealthEndpoint::readFailSafe()` aplica a los indicadores, y por la misma razón: un contribuyente roto debe degradar su propio panel, no el panel entero.
+
+!!! note "Los detalles de salud se leen del registro de contribuyentes, no a través del endpoint"
+ `show-details` vale `never` por defecto, y ese valor es el correcto — impide que un llamante HTTP anónimo aprenda el host de tu base de datos a partir de una conexión fallida. Aplicar esa política de divulgación HTTP al panel, sin embargo, producía un panel de Salud cuyo contenido entero era una disculpa que le decía al operador que fuera a cambiar una clave de configuración. El panel lee el `HealthContributorRegistry` directamente en su lugar, llamando a cada indicador de forma aislada, de modo que uno que lance se reporta DOWN con su motivo y nada más se ve afectado.
+
+---
+
+### El grafo de beans
+
+La mayoría de las páginas son tablas. Dos dibujan una imagen — esta, y el [mapa de entidades](#recorrer-el-modelo-relaciones-filtros-y-un-mapa) más adelante en el capítulo — y esta es la que amortiza el paquete el día en que algo está mal cableado.
+
+`/actuator/beans` te dice *qué* beans existen. No puede decirte a qué está **cableado** cada uno, que es lo que realmente quieres cuando un `#[ConditionalOnMissingBean]` no se disparó como esperabas, cuando un ciclo entre singletons ansiosos ha colgado un arranque sin mensaje alguno, o cuando intentas averiguar a qué se enganchó el paquete que acabas de instalar. `/firefly/graph` responde a eso, como un diagrama SVG por capas más una tabla de relaciones filtrable.
+
+No se refleja nada para construirlo. `ComponentScanner` ya registra, en tiempo de **escaneo**, los tipos de clase e interfaz que pide el constructor de cada componente, y esa lista viaja en el manifiesto compilado igual que cualquier otro hecho escaneado (abreviado):
+
+```php
+final class ComponentDescriptor
+{
+ public function __construct(
+ public string $class,
+ public string $stereotype,
+ public array $interfaces,
+ /**
+ * The class types this component's constructor asks for — the edges of the bean graph.
+ *
+ * Recorded at scan time, where reflection is already sanctioned, because the alternative is
+ * reflecting at request time to answer "what depends on what", which the reflection-free boot
+ * contract forbids. Only CLASS and INTERFACE types are kept: a scalar or a builtin is
+ * configuration, not a wiring edge, and putting it in the graph would drown the edges that matter.
+ */
+ public array $dependencies = [],
+ ) {}
+}
+```
+
+Esa última frase es una decisión de diseño en la que merece la pena detenerse. Un parámetro de constructor tipado `string $name` es configuración; dibujarlo como una arista enterraría las relaciones que importan bajo ruido de `string`/`int`. Un parámetro de **clase anulable o con valor por defecto** *sí* se conserva, porque un colaborador opcional sigue siendo una relación.
+
+#### Tres clases de nodo, y por qué la primera versión estaba casi vacía
+
+Antes que las aristas, los nodos — porque la primera versión de esta página los entendió mal de una forma de la que merece la pena aprender. Una aplicación LaraFly tiene **tres clases de bean**, y las tres tienen que ser nodos:
+
+| Clase | Qué es | De dónde sale |
+|---|---|---|
+| `component` | Una clase escaneada `#[Component]`/`#[Service]`/`#[Repository]`/`#[RestController]`/`#[Configuration]` | El catálogo de beans |
+| `bean` | Un valor **producido por un método fábrica `#[Bean]`** de una `#[Configuration]` | Las filas `produces` del catálogo |
+| `config` | Un DTO `#[ConfigProperties]` enlazado desde la configuración | El endpoint `configprops` |
+
+Al principio solo la primera clase era un nodo, y la consecuencia no fue cosmética. El cableado de un framework vive casi por completo en la segunda clase: una autoconfiguración es una `#[Configuration]` cuyos métodos `#[Bean]` producen `MeterRegistry`, `TransactionTemplate`, `AggregateTracker` y demás. Con solo las clases declarantes como nodos, cada arista que apuntaba a uno de esos productos apuntaba a un nodo que no existía. Medido sobre un esqueleto de serie: **42 nodos, 41 productos `#[Bean]` ausentes, 21 dependencias colgando y exactamente una arista dibujada.** La página no mostraba un grafo disperso — era estructuralmente incapaz de mostrar el cableado del framework.
+
+La tercera clase es el mismo error en miniatura. Un DTO `#[ConfigProperties]` está enlazado y es inyectable, pero no se escanea como componente ni lo produce una fábrica, así que nada en el catálogo de beans puede verlo: `App\GreetingProperties` aparecía como *dependencia no resuelta* de `GreetingService` en lugar de como el bean que es. Por eso la página lee el endpoint `configprops` junto al catálogo.
+
+De modo que también hay dos clases de arista, y dicen cosas distintas:
+
+| Arista | De → a | Significado |
+|---|---|---|
+| `injects` | Un bean → algo de lo que declaró depender | El consumidor lo pidió; el contenedor lo satisface |
+| `produces` | Una `#[Configuration]` → el valor que devuelve uno de sus métodos `#[Bean]` | Esta clase es de donde sale ese bean |
+
+Las aristas `injects` se recogen de los parámetros del **constructor** de un componente *y* de los parámetros de cada **método fábrica `#[Bean]`** — el producto depende de lo que su fábrica pidiera. Esa unión es el cableado; los constructores por sí solos son una fracción de él.
+
+Una sutileza sobre la identidad. Un producto `#[Bean]` se identifica normalmente por el **tipo que produce**, porque esa es la clave que enlaza el contenedor y la clave que pide todo consumidor. Pero cuando dos métodos fábrica producen el mismo tipo — la forma que las reglas `#[Primary]`/`#[Qualifier]` del Capítulo 2 existen para desambiguar — el tipo por sí solo los colapsaría en un único nodo y ocultaría justo la ambigüedad por la que abriste la página. Así que cada competidor recibe `Declarante::metodo()` como identidad propia y el tipo desnudo resuelve al primero de ellos, reflejando al contenedor, donde la clave de tipo es un alias del ganador mientras todo candidato sigue alcanzable por nombre.
+
+#### Lo difícil no es dibujar, es resolver
+
+Un constructor pide un **tipo**, y ese tipo es muy a menudo una interfaz — `EventPublisher`, `HealthIndicator`, `Cache` — mientras que el bean que lo satisface es una clase concreta que meramente la implementa. Una lista de aristas construida ingenuamente a partir de los tipos del constructor apunta entonces a nodos que no existen, y el grafo sale como un campo de puntos desconectados. Pregúntate a qué debería dibujar una flecha la dependencia de `WalletService` sobre `WalletRepository`: no al puerto, que es una interfaz sin bean propio, sino a `EloquentWalletRepository`, que es lo que de verdad se va a construir.
+
+Así que cada dependencia se resuelve a través de un índice de interfaces antes de convertirse en arista:
+
+```php
+foreach ($rows as $class => $row) {
+ foreach ($row['dependencies'] as $dependency) {
+ $target = isset($rows[$dependency]) ? $dependency : ($byInterface[$dependency] ?? null);
+
+ if ($target === null || $target === $class) {
+ // A type nothing in the container provides: a framework contract satisfied by a binding
+ // rather than a bean, or a class the scan never saw. Reported, not silently dropped —
+ // "why is my bean not in the graph" is exactly the question this page has to answer.
+ if ($target === null) {
+ $unresolved[] = $dependency;
+ }
+
+ continue;
+ }
+
+ $edges[] = ['from' => $class, 'to' => $target, 'via' => $target === $dependency ? null : $dependency];
+ }
+}
+```
+
+El miembro `via` es la honestidad de ese bucle. Cuando la arista pasó por una interfaz, el diagrama la marca y la columna **Wired by** de la tabla de relaciones nombra la interfaz, de modo que quien lee ve la indirección en lugar de que se le muestre calladamente una relación que nunca escribió. Cuando el constructor nombró la clase concreta, la columna simplemente dice `class`.
+
+El índice se construye en orden de catálogo y **gana el primer implementador**, de forma determinista — el catálogo se emite en orden de escaneo, así que la misma aplicación dibuja siempre el mismo grafo en lugar de rebarajarse entre máquinas. Una interfaz con varios implementadores es una ambigüedad real que el contenedor resuelve con `#[Primary]`/`#[Qualifier]`, y el grafo lo dice listando la arista como `via` en lugar de fingir que la elección era obvia.
+
+#### Capas, ciclos y el techo de nodos
+
+Los niveles salen de un recorrido de **camino más largo** sobre las aristas resueltas: la profundidad de un nodo es uno más que la de lo más profundo de lo que depende, y después los niveles se invierten para que el nivel 0 contenga aquello de lo que nada depende. El efecto es que un nodo siempre queda por debajo de todo lo que depende de él, las flechas se leen consistentemente hacia abajo, y la vista puede seguir una cadena desde un controlador hasta el repositorio que hay al final. La vista solo posiciona; los niveles vienen del modelo.
+
+La profundidad se memoiza y el recorrido lleva su propio conjunto de visitados, así que un ciclo termina en lugar de recursar para siempre — y la arista que lo cerró se *reporta*:
+
+```php
+foreach ($out[$node] ?? [] as $next) {
+ if (isset($path[$next])) {
+ $cycles[] = ['from' => $node, 'to' => $next];
+
+ continue;
+ }
+ $deepest = max($deepest, $walk($next, $path) + 1);
+}
+```
+
+Ese reporte vale más de lo que parece. El contenedor no tiene detección de ciclos propia, así que un ciclo entre singletons ansiosos no produce un error útil — agota la memoria en el arranque. Una página que nombra las dos clases implicadas convierte "la app murió sin mensaje" en un diagnóstico de cinco segundos, y el consejo del propio panel es el correcto: rompe una de estas aristas, normalmente inyectando una interfaz y dejando que el otro lado dependa de ella.
+
+Dos límites se declaran en la página en lugar de ocultarse:
+
+* **Pasados los `firefly.admin.graph.max-nodes` — 220 por defecto — el diagrama se suprime** y la tabla de Relaciones de abajo lleva la misma información como una lista filtrable. Un diagrama de más de un par de centenares de nodos es una maraña, no algo que una persona pueda leer, y renderizarlo de todos modos sería peor respuesta que negarse. Es una clave de configuración y no una constante porque "ilegible" depende de la pantalla y de la aplicación.
+* **"Provided outside the container" no es una advertencia.** Esas etiquetas son tipos de constructor satisfechos por un binding del contenedor de Laravel y no por un bean escaneado — la `Request`, el repositorio de configuración, una conexión. Se listan en lugar de descartarse en silencio precisamente porque *"¿por qué no está mi bean en el grafo?"* es la pregunta que la página tiene que responder. Un tipo que aparezca ahí y que esperabas que fuera un bean *tuyo* significa que tu escaneo no lo vio, y `firefly.scan.paths` es lo primero que hay que revisar.
+
+!!! tip "Léelo junto a la página de Condiciones"
+ Las dos responden mitades complementarias de toda sorpresa de auto-configuración. **Condiciones** dice *si* un bean del framework se registró o se echó atrás, y sobre qué condición. **El grafo** dice a qué está cableado el bean que sí ganó, y a través de qué interfaz. Una arista `EventPublisher` apuntando a `InMemoryEventPublisher` cuando configuraste `firefly.eda.provider=rabbitmq` se ve de un vistazo en el grafo; Condiciones nombra entonces el `#[ConditionalOnProperty]` que no casó.
+
+!!! note "Lo que el grafo sigue sin decidir por ti"
+ Dos límites merecen conocerse, y ninguno es una carencia de datos. **`#[Primary]`/`#[Qualifier]` no dirigen el índice** — gana quien escriba primero en orden de escaneo, tanto para una interfaz con varios implementadores como para la clave de tipo desnuda de un `#[Bean]` disputado. Cada competidor sigue teniendo su propio nodo y la arista se marca `via`, así que la ambigüedad es visible en la página, pero el destino dibujado puede no ser el que resuelve el contenedor. Y **un tipo no resuelto se reporta, nunca se explica**: la página puede decirte que un tipo lo provee algo fuera del contenedor, pero no *qué* enlace lo provee, porque un enlace del contenedor de Laravel no lleva descriptor que leer.
+
+---
+
+### El modelo de acceso es toda la frontera de seguridad
+
+Como el panel sortea la exposición, su propia URL es lo único que se interpone delante de `beans`, `env` y `conditions`. Por eso no debe estar encendido por defecto en producción, y por eso la bandera de activación está escrita como está:
+
+```php
+final readonly class AdminSettings
+{
+ public function __construct(
+ public bool $enabled,
+ public string $basePath,
+ public string $title,
+ // ... mas las opciones de presentacion: refreshSeconds, theme, graphMaxNodes, excludedPages.
+ ) {}
+
+ public static function fromConfig(Config $config): self
+ {
+ $base = trim($config->string('firefly.admin.base-path', '/firefly'), '/');
+
+ return new self(
+ enabled: $config->bool('firefly.admin.enabled', $config->bool('app.debug', false)),
+ basePath: $base === '' ? 'firefly' : $base,
+ title: $config->string('firefly.admin.title', $config->string('app.name', 'LaraFly')),
+ // ... firefly.admin.refresh-seconds (10, con suelo en 2), .theme (auto|light|dark),
+ // .graph.max-nodes (220) y .pages.exclude ('') tambien se leen aqui.
+ );
+ }
+}
+```
+
+`firefly.admin.enabled` **toma por defecto el valor de `app.debug`**. El razonamiento es que una aplicación que ya corre con debug encendido ya está sirviendo trazas de pila a quien las pida y es un entorno de desarrollo por definición, así que un panel ahí no divulga nada que no estuviera ya divulgado. Una aplicación con debug apagado ha hecho la afirmación contraria sobre sí misma, y debe optar por él explícitamente. Fijar la clave siempre gana sobre el valor por defecto de debug, en ambas direcciones — puedes apagar el panel en un entorno con debug, y encenderlo en uno de producción.
+
+!!! warning "Encenderlo fuera de debug es solo la mitad del trabajo"
+ `firefly.admin.enabled = true` con `app.debug = false` monta un panel que renderiza tu grafo de beans, tu configuración resuelta y tu tabla de rutas en una URL conocida, para cualquiera que pueda alcanzarla. El panel no trae **ninguna autenticación propia** — no tiene dependencia de código con `firefly/security` en absoluto, exactamente igual que `firefly/actuator`. Una aplicación que lo encienda fuera de debug **debe poner la ruta detrás de su propio middleware de autenticación**.
+
+Las reglas de `HttpSecurity` del Capítulo 10 lo hacen como pura configuración, del mismo modo que este capítulo ya aseguró el actuator — aquí tienes un despliegue que opta por el panel y le echa el candado en un solo fichero:
+
+```php
+ [
+ 'enabled' => true, // explicit: this deployment wants the dashboard with app.debug off
+ 'base-path' => '/firefly',
+ ],
+ 'security' => [
+ 'enabled' => true,
+ 'http' => [
+ 'enabled' => true,
+ 'rules' => [
+ ['pattern' => 'firefly', 'access' => 'hasRole:ADMIN'],
+ ['pattern' => 'firefly/*', 'access' => 'hasRole:ADMIN'],
+ ],
+ ],
+ ],
+];
+```
+
+`AdminRouteRegistrar` monta las dos rutas — un índice y un comodín `GET|POST {base}/{page}` — como un `BootPass` en `WiringPasses`, orden **60**, un paso por detrás del 50 de `ActuatorRouteRegistrar`, porque lee el registro que puebla ese pase. Se registran nativamente sobre el `Router` de Illuminate por la misma razón que las rutas del actuator y las del paquete OpenAPI: `firefly.admin.base-path` tiene que ser fijable por aplicación, y una ruta por atributo hornea su ruta literal dentro de un `RouteDescriptor` compilado. Cuando el panel está deshabilitado el pase no registra *absolutamente nada* — no hay ruta que adivinar ni manejador al que llegar.
+
+También se echa atrás en silencio en un caso más, fácil de pasar por alto. Blade es necesario para renderizar el panel y no es una dependencia del paquete, así que un despliegue solo-JSON sin factoría de vistas enlazada no obtiene rutas en vez de rutas que fallarían fatalmente en la primera petición; allí la superficie de gestión sigue siendo el actuator JSON.
+
+!!! warning "Tres cosas que el panel solo puede mostrarte de *este* proceso"
+ Bajo PHP-FPM cada petición es un proceso distinto, y tres páginas heredan eso. **Cambiar un nivel de log** llama al mismo endpoint que `POST /actuator/loggers/{name}`, que muta los manejadores de Monolog del proceso actual — la siguiente petición es otro proceso, así que cambia `logging.channels` para cualquier cosa que deba persistir. Las **métricas** son solo tan duraderas como el registro: el `SimpleMeterRegistry` por defecto guarda los medidores en memoria de proceso, así que el panel ve solo su propia petición salvo que `firefly.observability.metrics.store` apunte a un almacén de caché. Y los **detalles de salud** siguen ocultos en la respuesta JSON `/actuator/health` hasta que `firefly.management.endpoint.health.show-details` sea `always`, aunque la propia página de Salud del panel lea los indicadores directamente.
+
+### El navegador de datos, y por qué no hereda ese valor por defecto
+
+`firefly/admin` incluye una superficie más, y es la única de este capítulo cuya puerta está escrita de forma distinta a todas las que has visto. Es un **navegador de base de datos** al estilo del admin de Django sobre la capa de datos del Capítulo 5 — listado, detalle, búsqueda, ordenación y paginación sobre tus propios repositorios — al que se llega a través de un único `DataBrowser` que `DataBrowser::forContainer($container)` ensambla desde el contenedor de la aplicación.
+
+Descubre qué navegar igual que el resto del panel descubre todo lo demás — desde el catálogo compilado. **Todo bean cuya lista de interfaces de tiempo de escaneo contenga `CrudRepository` es un recurso navegable.** No se registra nada ni se declara nada: un repositorio que escribas es navegable en cuanto el contenedor lo tiene, y uno que borres deja de serlo sin que nadie edite una lista. Cada fila de `BeansCatalog` ya lleva el cierre completo de interfaces que `ComponentScanner` registró con `class_implements()`, así que «¿es este bean un repositorio, y además pagina?» son dos llamadas a `in_array()` sobre datos que el proceso ya tiene — sin reflexión y, de forma decisiva, sin posibilidad de ofrecer un recurso que el contenedor nunca registró.
+
+Ahora la puerta:
+
+```php
+final readonly class DataBrowserSettings
+{
+ public static function fromConfig(Config $config): self
+ {
+ $max = min(self::PAGE_SIZE_CEILING, max(1, $config->int('firefly.admin.data.max-page-size', 200)));
+
+ return new self(
+ enabled: $config->bool('firefly.admin.data.enabled', false),
+ writable: $config->bool('firefly.admin.data.writable', false),
+ pageSize: min($max, max(1, $config->int('firefly.admin.data.page-size', 25))),
+ maxPageSize: $max,
+ excluded: self::csv($config->string('firefly.admin.data.exclude', '')),
+ );
+ }
+
+ /** Escribir requiere AMBAS puertas. */
+ public function canWrite(): bool
+ {
+ return $this->enabled && $this->writable;
+ }
+}
+```
+
+Fíjate en los dos valores por defecto, y compáralos con el `$config->bool('app.debug', false)` de `AdminSettings` unas páginas más arriba. El panel sigue a `app.debug`, y el argumento para eso era sólido **para lo que el panel muestra**: beans, condiciones, mapeos y configuración resuelta son hechos sobre la *aplicación*, y una aplicación que ya sirve trazas de pila ya ha publicado hechos de esa clase.
+
+Esta página muestra hechos sobre los **usuarios** de la aplicación. Esa es una divulgación categóricamente mayor, y los errores que la exponen son los ordinarios, los que hoy no cuestan nada: una bandera de depuración olvidada en un entorno de staging que comparte base de datos con producción, un `.env` copiado a una máquina que debía ser interna, un portátil tunelizado para una demo. Cada uno se convierte en una divulgación de registros de clientes en cuanto hay un navegador de datos atado a `app.debug`. Así que la puerta es aparte, explícita y está cerrada — **`app.debug` no puede abrirla, y `firefly.admin.enabled` tampoco.** Las tres deben ser ciertas.
+
+Las escrituras necesitan entonces una *segunda* clave, y por sí sola no sirve de nada. Leer la fila equivocada es una divulgación; borrarla es pérdida de datos sin deshacer, desde un formulario, sobre una sesión que puede no ser más que «debug estaba encendido». Encender el navegador es una decisión sobre **visibilidad**; encender las escrituras es una decisión sobre **custodia**. Si las colapsas en una sola clave, quien quería mirar una tabla ha armado también el botón de borrar.
+
+!!! warning "Create existe para Eloquent, y se rechaza para todo lo demás"
+ El navegador no tuvo `create()` durante un tiempo, y el argumento era medio cierto. Un formulario de creación genérico sobre una entidad arbitraria es una promesa que no puede cumplir, y el Capítulo 6 explica por qué: **el constructor de un agregado es donde viven sus invariantes.** Un `Order` que debe tener al menos una línea, un `Wallet` cuyo saldo empieza a cero en la moneda con la que se abrió, un objeto valor que rechaza un IBAN mal formado — un formulario construido a partir de una lista de columnas no conoce ninguno. Solo hay dos formas de construir esa fila: llamar al constructor, que necesita argumentos que el formulario no puede suministrar con los tipos ni en el orden correctos; o escribir las columnas directamente en la tabla, lo que produce una fila que el modelo de dominio considera imposible. Lo segundo es lo que hace una implementación de «pues inserta las columnas», y es *peor que no tener botón*, porque parece que funcionó. **Ese caso se sigue rechazando, por su nombre.**
+
+ Nunca fue cierto para un modelo **Eloquent**, que se construye vacío y se rellena por atributo — exactamente lo que `update()` lleva haciendo siempre sobre una fila que ya existe. Create rechazaba por un riesgo que update ya estaba asumiendo, y la inconsistencia le costaba a toda aplicación una superficie CRUD que se quedaba en RUD. Así que se ofrece para un recurso respaldado por Eloquent bajo los dos mismos interruptores, con el identificador y cualquier columna enmascarada *omitidos del formulario* en lugar de deshabilitados en él: un campo que el navegador se negaría a escribir no debería aparentar que lo acepta.
+
+Merece la pena llevarse otras dos decisiones de esta sección, porque ambas parecen un detalle y no lo son.
+
+**El identificador y cualquier secreto enmascarado se rechazan como destino de una actualización** — y se rechazan dos veces, una para que la vista pueda dibujar el campo como solo lectura y otra en la ruta de escritura, de modo que un POST fabricado a mano no alcance lo que el formulario no ofrecía. Recodificar la clave de una fila desde un formulario genérico no es una edición, es otra fila, y las claves foráneas que apuntaban al valor antiguo no la siguen. El valor *mostrado* de un secreto es `******`, así que devolver un formulario renderizado escribiría la máscara sobre la credencial real — un fallo de pérdida de datos creado por el propio enmascarado. Los secretos se excluyen de la **búsqueda** por una razón emparentada: una caja que responde «sí, el `api_token` de alguna fila empieza por `sk_live_9`» es un oráculo que un operador puede recorrer carácter a carácter.
+
+**Ningún texto de error que la página muestre es jamás un mensaje de excepción.** La `QueryException` de Laravel convierte a cadena el SQL fallido *y sus bindings* dentro de `getMessage()`, así que reproducirlo publicaría el esquema y los valores enlazados — que en una búsqueda sobre una tabla de usuarios es la propia consulta del operador, y en una consulta de detalle es una clave primaria. Toda razón es una frase fija compuesta en la capa del navegador, más como mucho el nombre de clase de la excepción; el mensaje se queda en la excepción, donde el log puede tenerlo. Por eso mismo nada en la capa lanza hacia su llamador: las lecturas responden con un listado que lleva una razón, las escrituras con uno de cuatro resultados (`Done`, `Refused`, `NotFound`, `Failed`), y una vista que dibuja una página de administración nunca tiene que ser a prueba de excepciones para mantenerse en pie.
+
+!!! note "La ruta de listado que obtienes depende de la interfaz que implementaste"
+ Un `PagingAndSortingRepository` se pagina **en la base de datos**: el repositorio hace el desplazamiento, el límite, el `ORDER BY` y el `COUNT`, y el coste es independiente del tamaño de la tabla. Un `CrudRepository` simple no puede expresar nada de eso, así que el navegador llama a `findAll()`, ordena y corta **en PHP**, y descarta todas las filas menos 25 — lo que con diez mil filas es una página lenta y con diez millones es un agotamiento de memoria que mata al worker, al *primer* clic. La interfaz no tiene límite, ni desplazamiento, ni conteo con predicado, así que las opciones honestas eran «negarse a navegar repositorios que no pueden paginar» o «navegarlos y decir lo que cuesta». LaraFly hace lo segundo, y esto es el decirlo. Implementa `PagingAndSortingRepository` en todo lo que pretendas navegar contra una tabla real.
+
+### Recorrer el modelo: relaciones, filtros y un mapa
+
+Un listado que solo puedes desplazar es un volcado de tabla. Tres cosas lo convierten en algo que exploras.
+
+**Las relaciones se descubren LLAMANDO a los métodos que declaran una**, porque es la única forma de saber por qué columnas unen — el nombre de un método no dice nada y su tipo de retorno solo dice de qué clase es. Lo que convierte «qué es seguro llamar» en la pregunta que sostiene todo, y la respuesta es el **tipo de retorno declarado**: solo se llama a un método público, no estático y sin argumentos cuyo tipo de retorno sea una subclase de `Relation` de Eloquent. Un método que anuncia `: HasMany` es una definición de relación por construcción — la misma señal en la que se apoyan la validación de `with()` y las herramientas de IDE de Laravel — y un accesor no puede reclamarla sin mentir sobre su propia firma. La llamada no ejecuta consulta alguna; Eloquent la difiere hasta `get()`.
+
+Un `belongsTo` abre el único registro padre; un `hasMany` abre el listado hijo **filtrado por la clave de esta fila**, que es para lo que la página de registro necesita un filtro. Una relación cuyo otro extremo no es un recurso navegable se sigue mostrando — te dice la forma del modelo — pero no se enlaza, y esa distinción vive en el modelo y no en la plantilla para que una vista no pueda acuñar una URL que da 404.
+
+**Filtrar son ocho comparaciones sobre las columnas que el recurso ya publica** — `es`, `no es`, `contiene`, `empieza por`, `mayor que`, `menor que`, `está vacío`, `no está vacío` — expresadas como una URL GET que puedes guardar en marcadores o pegar en un ticket. Toda comparación enlaza su valor como parámetro, incluidas las de `LIKE`, donde los comodines van alrededor de un valor *escapado* en vez de meter el valor dentro de un patrón. Una columna que el esquema no publica y un operador fuera de ese conjunto se **descartan** en lugar de pasarse al driver: ambos llegan en una URL que un operador puede editar a mano, y una consulta que alcanza el driver con un identificador arbitrario dentro es, como poco, un oráculo de nombres de columna. Las condiciones se combinan con AND entre sí y con la caja de búsqueda, así que estrechar el listado de una relación no puede escaparse de ella.
+
+Las mismas ocho están implementadas para el camino de reserva en PHP, porque un repositorio que no puede paginar debe filtrarse por las mismas reglas que uno que sí. Dos implementaciones de un predicado divergen, y la divergencia se manifiesta como un filtro que significa cosas distintas en recursos distintos.
+
+**`/firefly/data-map` lo dibuja.** Cada entidad navegable como una caja con sus columnas, cada clave foránea como una arista etiquetada, y cada caja enlazando a sus propios registros. Un `hasMany` y el `belongsTo` que lo mira de frente son *una* clave vista desde dos extremos, así que cada una se dibuja una vez — apuntando desde la tabla que *tiene* la clave hacia la tabla a la que referencia, que es además lo que la flecha significa.
+
+### Dos páginas más, y la que sí cambia cosas
+
+**`/firefly/datasource`** responde a lo que un volcado de configuración no puede. ¿Con qué base de datos estoy hablando (driver, host, base, con `password` enmascarada por el mismo enmascarador que usa el endpoint `env`)? ¿Está *arriba* — se prueba una conexión por carga de página, porque abrir un socket puede quedarse colgado contra un host tras un cortafuegos y una página que abriera todas las conexiones configuradas tardaría en renderizar el timeout de la más lenta, justo en la página que abriste *porque* algo va mal. ¿Qué significa el pooling aquí — PHP no tiene pool de conexiones, y en vez de inventar un indicador la página informa del `ATTR_PERSISTENT` de PDO por lo que es, y dice que bajo php-fpm el tamaño efectivo del pool es tu número de workers. Y a qué compiló `#[Transactional]`, que hasta ahora solo existía como un artefacto bajo `bootstrap/cache`.
+
+**`/firefly/settings`** es la única página del panel que *cambia* la aplicación en lugar de describirla, y está protegida en consecuencia: `firefly.admin.settings.enabled` (desactivada por defecto, a diferencia de todo lo demás aquí), `.writable` encima, y una tercera puerta que **no es una clave de configuración** — en producción toda escritura se rechaza diga lo que diga el resto. Eso último es deliberado. Es la diferencia entre «lo hemos hecho seguro» y «lo hemos hecho configurable para que sea seguro», y solo lo primero sobrevive a que alguien copie un `.env`.
+
+Es un *interruptor de funcionalidad*, no un endpoint de configuración remota: la lista es fija y propiedad del framework, así que un POST fabricado que nombre `app.key` o el host de una base de datos no encuentra nada que escribir. Un cambio va a un único fichero JSON bajo `bootstrap/cache`, filtrado tanto a la entrada como a la salida, y se mezcla sobre la configuración en el `register()` del provider — no en un boot pass, y esa distinción costó una sesión de depuración. Todo objeto de settings de este framework se construye una vez desde la configuración y se retiene, así que aplicar los overrides desde el propio pase del panel escribía el fichero y mostraba el nuevo estado en la página mientras `/openapi.json` seguía respondiendo 200. `register()` corre antes de que se resuelva ningún bean, que es el único punto en el que la mezcla es cierta.
+
!!! laravel "Paridad con Laravel"
- Laravel puro no trae ningún endpoint de comprobación de salud ni de métricas en absoluto — la mayoría de los equipos o bien improvisan una ruta `/health` a mano o recurren a un paquete de terceros, normalmente emparejado con la extensión `ext-prometheus`. `firefly/actuator` y `firefly/observability` son análogos de primera parte y con pocas dependencias de Spring Boot Actuator y Micrometer respectivamente: endpoints de framework montados sobre el mismo `Router` que tu app ya usa, comprobaciones de salud que reutilizan por debajo los propios `DB`/`Log`/config de Laravel, y un exportador Prometheus en PHP puro sin requisito de extensión. Ambos paquetes son dependencias Composer opcionales y ambos son seguros por defecto — una app que añade `firefly/actuator` obtiene `health`/`info` y nada más hasta que configure más.
+ Laravel puro no trae ningún endpoint de comprobación de salud ni de métricas en absoluto — la mayoría de los equipos o bien improvisan una ruta `/health` a mano o recurren a un paquete de terceros, normalmente emparejado con la extensión `ext-prometheus`. `firefly/actuator` y `firefly/observability` son análogos de primera parte y con pocas dependencias de Spring Boot Actuator y Micrometer respectivamente: endpoints de framework montados sobre el mismo `Router` que tu app ya usa, comprobaciones de salud que reutilizan por debajo los propios `DB`/`Log`/config de Laravel, y un exportador Prometheus en PHP puro sin requisito de extensión. Ambos paquetes son dependencias Composer opcionales y ambos son seguros por defecto — una app que añade `firefly/actuator` obtiene `health`/`info` y nada más hasta que configure más. `firefly/admin` completa el conjunto como análogo de Spring Boot Admin, con la diferencia de que no es una aplicación de monitorización aparte que despliegas y en la que registras instancias: son vistas Blade dentro de la propia aplicación sobre la que informan, que es por lo que puede leer el registro directamente y por lo que su modelo de acceso importa tanto como importa.
---
@@ -667,6 +1014,19 @@ Por último, un puerto `Tracer` remata el paquete — una abstracción mínima d
| `PrometheusTextFormat` | Exposición a prueba de locale — `number_format()`, nunca `sprintf('%f')` |
| `MetricsFilter` | Filtro de cronometraje más externo `#[Order(-100)]`; etiqueta por la **plantilla** de la ruta, nunca la ruta en bruto — cardinalidad acotada |
| `ObservabilityAutoConfiguration` `#[Order(500)]` | El mismo truco de precedencia que la costura de seguridad del Capítulo 10: registra `cqrsMetrics()` antes de que `CqrsAutoConfiguration` evalúe su `#[ConditionalOnMissingBean]` |
+| `firefly/admin` | Un panel Blade renderizado en el servidor en `/firefly`; una cuyo endpoint no está registrado o está apagado se oculta del menú en lugar de enlazarse |
+| `AdminEndpointReader` | Invoca cada `ActuatorEndpoint` **en proceso** desde el `ActuatorRegistry`, sorteando `ExposureModel` — así el panel muestra lo que la superficie HTTP no expone, y un endpoint que lanza degrada un solo panel |
+| `BeanGraph` | Convierte el catálogo de beans en un grafo de dependencias dibujado sobre **tres clases de nodo** — componentes, productos `#[Bean]` y DTOs `#[ConfigProperties]` — con aristas `injects`/`produces` resueltas a través de un índice de interfaces (marcadas `via`), estratificación por camino más largo, ciclos reportados en lugar de colgarse, y el diagrama suprimido pasados `firefly.admin.graph.max-nodes` (220) |
+| Los productos `#[Bean]` como nodos | El cableado de un framework vive en métodos fábrica, no en constructores; con solo las clases declarantes como nodos, un esqueleto de serie dibujaba **una** arista de 42 beans |
+| `ComponentDescriptor::$dependencies` | Las aristas del grafo, registradas por `ComponentScanner` en tiempo de **escaneo** — solo tipos de clase e interfaz, porque un parámetro escalar es configuración, no cableado |
+| `firefly.admin.enabled` | Toma por defecto `app.debug`; un valor explícito gana en ambas direcciones, y encenderlo con debug apagado te obliga a poner tu propio middleware de autenticación delante de la ruta |
+| `firefly.admin.data.enabled` | La puerta propia del navegador de datos, con valor por defecto **`false`** — *no* sigue a `app.debug` ni a `firefly.admin.enabled`, porque esta página muestra hechos sobre los usuarios de la aplicación y no sobre la aplicación |
+| `RelationIntrospector` | Encuentra relaciones LLAMANDO solo a los métodos cuyo tipo de retorno declarado es una `Relation` de Eloquent, para que un registro enlace con lo que referencia y el mapa de entidades tenga aristas que dibujar |
+| `DataFilter` | Ocho comparaciones sobre las columnas que el recurso publica, siempre enlazadas como parámetro, y con una columna u operador desconocido descartado en lugar de pasado al driver |
+| `/firefly/datasource` | Conexiones con los secretos enmascarados, una probada por carga, la persistencia de PDO contada por lo que es, y el contrato `#[Transactional]` compilado |
+| `/firefly/settings` | La única página que cambia la aplicación: desactivada por defecto, escribible con una segunda clave, y rechazada en producción por una puerta que ninguna clave levanta |
+| `firefly.admin.data.writable` | Una **segunda** puerta, también `false` e inútil por sí sola: visibilidad y custodia son decisiones distintas, y una sola clave armaría el botón de borrar para quien solo quería mirar una tabla |
+| Sin `create()` | Permanente, no pendiente: las invariantes de un agregado viven en su constructor, y un formulario construido a partir de una lista de columnas no puede satisfacerlas — escribir las columnas de todos modos produce una fila que el dominio considera imposible |
---
@@ -675,3 +1035,6 @@ Por último, un puerto `Tracer` remata el paquete — una abstracción mínima d
1. **Añade un `HealthIndicator` personalizado.** Escribe un `#[Component]` que implemente `HealthIndicator` que compruebe algo específico de tu propia app (un feature flag, la profundidad de una cola, una conexión de caché) y confirma que aparece en el mapa `components` de `GET /actuator/health` una vez que `show-details` esté configurado a `always`.
2. **Configura una división liveness/readiness real.** Añade `firefly.management.endpoint.health.group.liveness.include = 'ping'` y `...readiness.include = 'ping,db'` (con el indicador de BD habilitado) a la configuración de un proyecto de pruebas, y confirma que `GET /actuator/health/liveness` y `GET /actuator/health/readiness` divergen en el momento en que dejas la base de datos inalcanzable.
3. **Observa a la costura de métricas de CQRS ganar la carrera.** Instala `firefly/observability` en un proyecto de pruebas que ya use `firefly/cqrs`, envía un puñado de comandos, e inspecciona `GET /actuator/prometheus` en busca de muestras de `cqrs_commands_seconds` — luego comenta temporalmente el atributo `#[Order(500)]` de `ObservabilityAutoConfiguration` (revirtiendo al valor por defecto de la clase) y confirma si la métrica todavía aparece, para ver el truco de ordenamiento importar de verdad en lugar de solo leer sobre él.
+4. **Demuéstrate a ti mismo el sorteo de la exposición.** Instala `firefly/admin` en el sample, deja `firefly.management.endpoints.web.exposure.include` en su valor por defecto, y confirma que `GET /actuator/beans` devuelve un `404` mientras `/firefly/beans` renderiza la lista completa de beans en el mismo proceso. Luego pon `firefly.management.endpoint.beans.enabled` a `false` y confirma que la entrada Beans desaparece del menú del panel — el interruptor de apagado se honra allí donde la exposición no, y la diferencia entre ambas claves es todo el diseño.
+5. **Dibuja tu propio cableado y luego rómpelo.** Abre `/firefly/graph` en el sample y encuentra la flecha de `WalletService` a `EloquentWalletRepository` — fíjate en que la columna *Wired by* dice `WalletRepository`, el puerto, y no `class`. Después introduce un ciclo deliberado (haz que un `#[Service]` tome un parámetro de constructor tipado como otro `#[Service]` que ya depende de él), recarga la página, y confirma que la estadística **Cycles** se pone en rojo y nombra ambas clases. Ahora arranca la app de cero sin abrir el panel, y compara lo que PHP te cuenta sobre ese mismo ciclo.
+6. **Lee el valor por defecto de acceso como una decisión de seguridad.** Pon `app.debug` a `false` en un proyecto de pruebas con `firefly/admin` instalado y confirma que `/firefly` está genuinamente sin enrutar y no simplemente sin enlazar (`php artisan route:list` no debería listarla). Luego pon `firefly.admin.enabled` a `true` sin añadir ninguna regla de `HttpSecurity`, y mira qué divulga ahora un `GET /firefly/env` sin autenticar — esa es exactamente la brecha que este capítulo te dijo que cerraras con tu propio middleware de autenticación.
diff --git a/book/src/04a-openapi.md b/book/src/04a-openapi.md
new file mode 100644
index 0000000..e2ef7eb
--- /dev/null
+++ b/book/src/04a-openapi.md
@@ -0,0 +1,796 @@
+Part I — Foundations · Chapter 4A
+
+# Documenting the API: OpenAPI 3.1 from the Manifests {.chtitle}
+
+By the end of this chapter you will know how `firefly/openapi` turns the `RouteManifest` and `ConstraintManifest` Chapter 4 just built into a valid OpenAPI 3.1 document with **no annotation dialect of its own** — how `firefly:openapi` makes that document a build artifact a CI job can diff, how each binding `kind` in the compiled route plan becomes a Parameter Object or a Request Body, how a `#[NotBlank]` or a `#[Positive]` you already wrote becomes a `pattern` or an `exclusiveMinimum`, why every DTO is registered once and reached by `$ref` rather than inlined, why every operation carries the same `problem+json` error component Chapter 4's renderer produces, and why a `#[Controller]` HTML route is left out of the document by default. It closes on the browser console the package serves over that document — three styles, of which the default is the **official Swagger UI served from your own origin**, with no npm step and no CDN request, and only one of the three ever talks to a third party.
+
+!!! note "New term: specification extension"
+ OpenAPI 3.1 lets a document carry members whose names begin with `x-`, called **specification extensions**. Conforming tools must ignore them, so an extension can record something the standard vocabulary cannot express without making the document invalid. `firefly/openapi` uses exactly one, `x-firefly-constraints`, and this chapter shows what lands in it and why nothing is ever dropped silently instead.
+
+---
+
+## Nothing to annotate
+
+Every OpenAPI toolchain in PHP that predates this one asks you to write the document twice: once as code the server runs, and once as annotations, attributes or a YAML file that *describe* the code the server runs. The two drift the first time somebody adds a field in a hurry, and the drift is invisible — the document still validates, it just no longer matches the server.
+
+`firefly/openapi` does not have that failure mode available to it, because it does not have a second source. Chapter 4 ended with `RouteManifest`: the compiled table of every `RouteDescriptor` the dispatcher serves from, carrying the verb, the path, the declared status, the route name, the controller class and method, and the per-parameter *binding plan*. Chapter 4 also introduced `ConstraintManifest`: the compiled rule list `BeanValidator` runs on a `#[Valid]` body. Those two artifacts, plus `firefly/kernel`'s `ErrorResponse`, are the entire input:
+
+```bash
+composer require firefly/openapi
+```
+
+That is the whole installation. Boot the app and `GET /openapi.json` is served; `GET /openapi` renders a reference console over it. Nothing was annotated, and nothing can drift, because every fact in the document is read from the same compiled artifact the dispatcher reads.
+
+---
+
+## `firefly:openapi`, and routes that are not attribute routes
+
+The document is also a file you can commit:
+
+```bash
+php artisan firefly:openapi --output=docs/openapi.json # writes the file, prints a summary line
+php artisan firefly:openapi > openapi.json # writes the raw document to stdout
+```
+
+The command exists so the document can be a **build artifact** rather than only a live endpoint. Committing the generated file is what lets a CI job diff it and fail a pull request that changed the public API without saying so, and what lets a front-end repository regenerate its typed client from a checked-in spec without booting the PHP application at all. It is also the only way to get a document out of a deployment that keeps `firefly.openapi.enabled` off in production.
+
+Stdout mode is written with Symfony's `OUTPUT_RAW` flag, and that detail matters more than it looks: console output normally goes through Symfony's formatter, which treats `<...>` as markup. A `description` mentioning a generic type — anything carrying an angle bracket that reached the document from a config value — would either be swallowed or would throw on an unknown tag. The point of stdout mode is to pipe straight into a client generator, so the bytes must be exactly the bytes of the document. That is also why the confirmation line is printed **only** in `--output` mode, where stdout is not the document.
+
+The HTTP routes — the spec, the console, and the console's own assets — are mounted natively on the Illuminate `Router` from a `BootPass`, not declared with `#[GetMapping]`:
+
+```php
+final class OpenApiRouteRegistrar implements BootPass
+{
+ public function phase(): BootPhase
+ {
+ return BootPhase::WiringPasses;
+ }
+
+ public function order(): int
+ {
+ return 60;
+ }
+
+ public function run(BootContext $context): void
+ {
+ $container = $context->container;
+
+ /** @var OpenApiProperties $properties */
+ $properties = $container->make(OpenApiProperties::class);
+
+ if (! $properties->enabled) {
+ return;
+ }
+
+ /** @var Router $router */
+ $router = $container->make('router');
+
+ $router->get($properties->specPath, static fn (): mixed => $container->make(OpenApiSpecAction::class)())
+ ->name('firefly.openapi.spec');
+
+ if (! $properties->viewerEnabled) {
+ return;
+ }
+
+ $router->get($properties->viewerPath, static fn (): mixed => $container->make(OpenApiViewerAction::class)())
+ ->name('firefly.openapi.viewer');
+
+ // The official Swagger UI files, served from this application's own origin rather than a CDN. Mounted
+ // under the viewer path so moving the console moves its assets with it, and constrained to a single
+ // path segment so the route cannot express a traversal in the first place — SwaggerAssets whitelists
+ // and realpath-checks the name as well.
+ $router->get($properties->viewerPath.'/assets/{file}', static fn (string $file): mixed => $container->make(SwaggerAssetAction::class)($file))
+ ->where('file', '[A-Za-z0-9._-]+')
+ ->name('firefly.openapi.assets');
+ }
+}
+```
+
+This is the same shape — and the same `BootPass` idiom — Chapter 11 will show you for the actuator's own routes, and it is chosen for two independent reasons. First, **an attribute route cannot be configurable**: `#[GetMapping('/openapi.json')]` bakes its literal into a compiled `RouteDescriptor` at `firefly:cache` time, so an operator could never move the spec off a path that collides with one of their own, and could never take it off a public surface without deleting the package. Second, an attribute route would enter the application's `RouteManifest` — and the generator reads that manifest, so **the package would document itself**. Registering natively keeps both problems out of existence: the paths come from config at boot, and they never appear in the spec they serve. Note that the assets route is mounted *under* the viewer path, so moving the console moves its stylesheet and scripts with it.
+
+`firefly.openapi.enabled` (default `true`) is enforced *here*, on the routes, rather than on the beans — the generator and its collaborators are inert without routes, so gating the routes is the whole of the switch. Turning it off leaves every one of those paths genuinely unrouted, so they 404 through the router's own `NotFoundHttpException`, which Chapter 4's `ProblemDetailsRenderer` then renders as a proper `404` problem-details body rather than a `500`.
+
+!!! note "The actions are resolved per request, inside the closure"
+ Building an `OpenApiSpecAction` at boot and capturing it in the route would freeze one `OpenApiGenerator` into the route for the process's lifetime — exactly the shape that breaks under Octane, where a later request's container is a different sandbox. `$container->make(...)` *inside* the closure is the rule, here and in every other framework package that mounts a native route.
+
+---
+
+## From `RouteManifest` to Operation Objects
+
+`OpenApiGenerator::generate()` is the entry point — it memoises a single private `build()` pass (`$this->document ??= $this->build()`), and `toJson()` wraps it for a file or an HTTP body. That pass walks the manifest once, skipping excluded routes, and hands each survivor to `OperationFactory`. Everything about the result is deterministic on purpose:
+
+```php
+final class OpenApiGenerator
+{
+ private const array VERB_ORDER = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'];
+
+ private function sortVerbs(array $item): array
+ {
+ $sorted = [];
+
+ foreach (self::VERB_ORDER as $verb) {
+ if (array_key_exists($verb, $item)) {
+ $sorted[$verb] = $item[$verb];
+ unset($item[$verb]);
+ }
+ }
+
+ ksort($item);
+
+ return [...$sorted, ...$item];
+ }
+}
+```
+
+Paths are sorted, verbs within a path are sorted into the canonical order the OpenAPI specification itself lists them in, and the schema registry sorts components by name. This is not tidiness for its own sake. Route discovery order depends on filesystem iteration, so an *unsorted* document would reshuffle itself between machines and turn every regeneration into an unreviewable diff — which is exactly what makes teams stop committing the generated file, which is what makes it go stale.
+
+Inside an operation, the binding plan does the real work. `OperationFactory` dispatches on the same `kind` discriminator `ArgumentResolver` uses at request time:
+
+```php
+final class OperationFactory
+{
+ public function create(RouteDescriptor $route, string $operationId, SchemaRegistry $registry): array
+ {
+ $parameters = [];
+ $body = null;
+ $files = [];
+ $validated = false;
+ $rejectable = false;
+
+ foreach ($route->bindings as $binding) {
+ $validated = $validated || $binding['valid'];
+
+ switch ($binding['kind']) {
+ case 'path':
+ $parameters[] = $this->parameter($binding, 'path', true);
+ $rejectable = $rejectable || $this->coercible($binding);
+ break;
+ case 'query':
+ $parameters[] = $this->parameter($binding, 'query', $binding['required']);
+ $rejectable = $rejectable || $binding['required'] || $this->coercible($binding);
+ break;
+ case 'header':
+ $parameters[] = $this->parameter($binding, 'header', $binding['required']);
+ $rejectable = $rejectable || $binding['required'] || $this->coercible($binding);
+ break;
+ case 'file':
+ $files[] = $binding;
+ $rejectable = true;
+ break;
+ case 'body':
+ $body = $binding;
+ $rejectable = true;
+ break;
+ }
+ }
+
+ // …the operation's own prose (operationId, summary, description, tags) is assembled here…
+
+ if ($parameters !== []) {
+ $operation['parameters'] = $parameters;
+ }
+
+ if ($body !== null) {
+ $operation['requestBody'] = $this->requestBody($body, $registry);
+ } elseif ($files !== []) {
+ $operation['requestBody'] = $this->multipartBody($files);
+ }
+
+ $operation['responses'] = $this->responses($route, $rejectable, $validated);
+
+ return $operation;
+ }
+}
+```
+
+The one elided block is where the operation's human-facing prose is put together; everything shown is what the *binding plan* decides. Reading the plan rather than re-reading the method signature is what makes the mapping unambiguous. `#[PathVariable]`, `#[QueryParam]` and `#[RequestHeader]` become Parameter Objects; `#[UploadedFile]` becomes a `multipart/form-data` part typed `format: binary`; `#[RequestBody]` becomes the Request Body Object; and the sixth `kind`, `service` — the no-attribute container-injected collaborator Chapter 4 introduced — is not part of the HTTP contract at all and never appears in the document. Deriving that list independently would have to re-decide every one of those cases and could disagree with the dispatcher; reading the plan cannot.
+
+Four smaller decisions finish an operation:
+
+- **Path template.** Laravel's optional-parameter spelling `{id?}` has no OpenAPI equivalent — a path parameter is required there, full stop — so the marker is stripped and the parameter stays required. Emitting two Path Items instead would describe a router surface that does not exist and would double every such operation in a generated client.
+- **`operationId`.** The route's `name` when it has one, otherwise derived as the controller's short name minus a trailing `Controller`, lower-cased, plus the method name: `Lumen\Web\WalletController::balance()` becomes `walletBalance`. Because `operationId` must be unique across the whole document — and a duplicate is the one flaw that makes most client generators abort rather than degrade — a repeat claim is suffixed (`walletBalance_2`) rather than allowed to overwrite.
+- **`tags`.** The same short name, so every `WalletController` operation groups under "Wallet" in a viewer.
+- **`summary`.** Split from the method name: `getBalance` reads as "Get balance". A method name is the only human-authored label a route carries — a `#[Mapping]`'s `name` is a Laravel route name, not prose — so it is the honest source.
+
+---
+
+## Request bodies: one component per DTO, reached by `$ref`
+
+Chapter 4's `OpenWalletRequest` is the running example again:
+
+```php
+final class OpenWalletRequest
+{
+ public function __construct(
+ #[NotBlank]
+ public readonly string $owner_id,
+ #[NotBlank]
+ #[CurrencyCode]
+ public readonly string $currency,
+ ) {}
+}
+```
+
+`POST /api/v1/wallets` binds it with `#[Valid] #[RequestBody]`, and the generated operation refers to it rather than repeating it:
+
+```json
+{
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/OpenWalletRequest" }
+ }
+ }
+ }
+}
+```
+
+`SchemaRegistry` is what makes that `$ref` possible, and it solves two problems a naive "inline the schema at every use site" generator has. The first is **duplication**: a DTO used by six operations would be emitted six times, and every generated client would mint six structurally identical anonymous types with six different names. Registering once and referring by pointer is what makes `openapi-generator`, `orval` and `kiota` produce *one* named type per DTO — which is the whole point of generating the document in the first place. The second is **recursion**: a DTO with a `#[Valid] ?self $parent` member cannot be inlined at all, because the expansion does not terminate. The registry therefore reserves the component name *before* invoking the builder, so a nested call for the same class finds the name already taken and returns the pointer immediately, closing the cycle. A `$ref` cycle is legal and useful in a document where a flattened rule list would be infinite.
+
+Component names are the class's short name, because that is what a human reads in a viewer and what a generator turns into a type name. Two DTOs sharing a short name across namespaces — `Order\Dto\Address` and `Billing\Dto\Address` — would collide and one would silently overwrite the other, so the **second** claimant of a name falls back to its dotted fully-qualified name: ugly, unambiguous, and rare. First claimant wins, so adding a second `Address` elsewhere in the app never renames the one that was already published.
+
+Two properties of the emitted schema are worth stating explicitly because both are decisions rather than omissions. The member list is the **constructor's parameter list in declaration order**, because that is exactly what `ArgumentResolver` hydrates from — but a constraint keyed to a member with no constructor parameter is still documented, because `BeanValidator` validates the raw decoded array and so enforces it on input regardless. And **no `additionalProperties: false` is ever emitted**: the server genuinely ignores keys outside the constructor's list, so a spec claiming otherwise would make conforming clients reject requests the server would have served.
+
+---
+
+## Constraints become schema keywords
+
+Neither half of a DTO is enough on its own. The `ConstraintManifest` knows the *validation* contract but nothing about types, because a rule list is untyped by construction. The constructor knows the *type* contract — `?int`, a backed enum, a nested DTO, a default — but nothing about the constraints. Types-only would document `#[NotBlank] string $name` as an unbounded string; constraints-only would document `int $quantity` as a string. `DtoSchemaFactory` merges them, and `ConstraintSchemaMapper` maps the second half.
+
+It maps the **compiled manifest**, never the `#[Constraint]` attributes. Reading the attributes back off the DTO would be the obvious route to "`#[Email]` → `format: email`", and it would document a validator that does not exist: the manifest has already applied `ConstraintScanner`'s Jakarta null contract, already expanded `#[Size]` into a first-party rule *object* rather than Laravel's polymorphic `min:`/`max:` strings, and already flattened one `#[Valid]` level into dotted keys. Generating from the attributes would re-derive all of that by hand and drift from it the first time a `toRules()` body changed. Generating from the manifest cannot drift, because the manifest *is* the contract.
+
+Here is the real mapping, constraint by constraint, with the compiled rules in the middle column so you can see that the mapper is reading rules and not attributes:
+
+| Constraint | Compiles to | JSON Schema |
+|---|---|---|
+| `#[NotBlank]` | `required`, `string`, `regex:/\S/` | member added to `required`; `type: string`; `pattern: \S` |
+| `#[NotEmpty]` | `required` | member added to `required` |
+| `#[NotNull]` | `present` + a `NotNull` rule object | member added to `required` **and** `null` removed from the type union |
+| `#[Min(n)]` / `#[Max(n)]` | `numeric`, `gte:n` / `lte:n` | `minimum` / `maximum` |
+| `#[Positive]` / `#[Negative]` | `numeric`, `gt:0` / `lt:0` | `exclusiveMinimum: 0` / `exclusiveMaximum: 0` |
+| `#[PositiveOrZero]` / `#[NegativeOrZero]` | `numeric`, `gte:0` / `lte:0` | `minimum: 0` / `maximum: 0` |
+| `#[Email]` | `email` | `type: string`, `format: email` |
+| `#[Pattern(re)]` | `regex:re` | `pattern`, with the PCRE delimiters and the no-op `D`/`u` flags stripped |
+| `#[Digits(i, f)]` | `numeric`, an anchored `regex:` | `type: number` + `pattern` |
+| `#[AssertTrue]` / `#[AssertFalse]` | `accepted` / `declined` | `type: boolean` + `const: true` / `const: false` |
+| `#[Future]` / `#[Past]` | `date`, `after:now` / `before:now` | `type: string`, `format: date-time` (the `after:now` half is recorded, not mapped) |
+| `#[Size(min, max)]` | a `Size` rule object | `minLength`/`maxLength`, or `minItems`/`maxItems` when the type is an array |
+| `#[UuidValue]` | a `Uuid` rule object | `format: uuid` + the UUID `pattern` |
+| `#[Phone]` | an `E164` rule object | `format: phone` + `pattern: ^\+[1-9]\d{1,14}$` |
+| `#[CurrencyCode]` / `#[CountryCode]` | `Currency` / `CountryCode` rule objects | `format: currency` + `^[A-Z]{3}$` / `format: country-code` + `^[A-Z]{2}$` |
+| `#[LanguageTag]` / `#[PostalCode]` | rule objects | `format: bcp47` / `format: postal-code`, each with its pattern |
+| `#[Iban]` / `#[Swift]` / `#[Bic]` | rule objects | `format: iban` / `swift` / `bic`, **pattern withheld** |
+| `#[Cusip]` / `#[Isin]` / `#[Luhn]` / `#[RoutingNumber]` | rule objects | `format` only; the check digit is recorded, not mapped |
+| `#[Percentage]` | a `Percentage` rule object | `type: number`, `minimum: 0`, `maximum: 100` |
+| `#[Money]` | a `PositiveMoney` rule object | `type: number`, `exclusiveMinimum: 0`, `multipleOf: 0.01` |
+| `#[DecimalScale(n)]` | a `DecimalScale` rule object | `multipleOf` — scale 2 becomes `0.01` |
+
+Three rows in that table repay a closer look.
+
+**The pattern is withheld where the rule normalises first.** `Iban` strips spaces and upper-cases before it matches; `Bic`, `Swift`, `Cusip` and `Isin` upper-case; `Luhn` and `RoutingNumber` strip separators. Publishing the post-normalisation pattern would reject payloads the server happily accepts, which is worse than under-specifying — so the `format` is emitted and the pattern is not.
+
+**`format` is an open vocabulary.** In JSON Schema 2020-12 an unknown `format` is an annotation, not an error. IBAN, BIC, ISIN, CUSIP and E.164 have no registered format name, so self-describing ones (`iban`, `bic`, …) are emitted rather than nothing.
+
+**Nullability is spelled the 3.1 way.** OpenAPI 3.1 *is* JSON Schema 2020-12, which dropped 3.0's `nullable: true` keyword in favour of a type union. A nullable member is therefore `"type": ["string", "null"]`, and an `enum` additionally gains a `null` member — widening the type alone would leave `null` failing the enumeration, making the property undocumentable-as-null in practice.
+
+The one rule that decides the whole merge is **first writer wins**, applied first to the declared type and then to the rules in declaration order — the same order the validator applies them in:
+
+```php
+final class MapperState
+{
+ public function keyword(string $keyword, mixed $value): void
+ {
+ if (! array_key_exists($keyword, $this->schema)) {
+ $this->schema[$keyword] = $value;
+ }
+ }
+}
+```
+
+Seeding the declared PHP type *before* any rule is seen is why `#[Min(1)] int $quantity` stays `type: integer` instead of being widened to `number` by the `numeric` rule string `#[Min]` emits — a widening that would wrongly document `1.5` as acceptable.
+
+Here is that whole pipeline on one real DTO. This is the generator's own test fixture, chosen because it deliberately spans every mapping route the generator has — a length-bounded string, a Jakarta null-contract nullable, an int with numeric bounds, a scaled decimal, a backed enum, a nested `#[Valid]` DTO, a PCRE pattern and a rule object:
+
+```php
+final class CreateOrderRequest
+{
+ public function __construct(
+ #[NotBlank] #[Size(max: 64)] public readonly string $reference,
+ #[NotNull] #[Email] public readonly string $email,
+ #[Min(1)] #[Max(999)] public readonly int $quantity,
+ #[Positive] #[DecimalScale(2)] public readonly float $amount,
+ public readonly Currency $currency,
+ #[Valid] public readonly AddressPayload $shipTo,
+ #[Pattern('/^[A-Z]{3}-\d{4}$/D')] public readonly ?string $coupon = null,
+ #[UuidValue] public readonly ?string $idempotencyKey = null,
+ ) {}
+}
+```
+
+…and this is the component it generates, verbatim:
+
+```json
+{
+ "type": "object",
+ "title": "CreateOrderRequest",
+ "properties": {
+ "reference": { "type": "string", "maxLength": 64, "pattern": "\\S" },
+ "email": { "type": "string", "format": "email" },
+ "quantity": { "type": "integer", "minimum": 1, "maximum": 999 },
+ "amount": { "type": "number", "exclusiveMinimum": 0, "multipleOf": 0.01 },
+ "currency": { "type": "string", "enum": ["EUR", "USD"] },
+ "shipTo": { "$ref": "#/components/schemas/AddressPayload" },
+ "coupon": { "type": ["string", "null"], "pattern": "^[A-Z]{3}-\\d{4}$" },
+ "idempotencyKey": {
+ "type": ["string", "null"],
+ "format": "uuid",
+ "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
+ }
+ },
+ "required": ["reference", "email", "quantity", "amount", "currency", "shipTo"]
+}
+```
+
+Every member of that object is traceable to something in the class above: the enum came from the backed enum's own cases, the `$ref` from `#[Valid]`, the two nullable unions from `?string`, the `multipleOf` from `#[DecimalScale(2)]`, and `required` from the presence rules — `coupon` and `idempotencyKey` are absent from it because nothing asserts their presence.
+
+Lumen's own DTOs show the same machinery at a smaller scale, and one of them shows a case the table above cannot: a property carrying **two** patterns.
+
+```json
+{
+ "owner_id": { "type": "string", "pattern": "\\S" },
+ "currency": {
+ "type": "string",
+ "format": "currency",
+ "allOf": [{ "pattern": "\\S" }, { "pattern": "^[A-Z]{3}$" }]
+ }
+}
+```
+
+`#[NotBlank] #[CurrencyCode] string $currency` genuinely produces two patterns — `\S` from the non-blank rule and `^[A-Z]{3}$` from the currency rule — and JSON Schema has exactly one `pattern` slot per schema object. Collapsing them by keeping the last would silently drop the non-blank guarantee, so several patterns become an `allOf` of single-pattern subschemas instead. One pattern stays a plain `pattern`; the `allOf` appears only when it has to.
+
+---
+
+## Nothing is dropped silently
+
+Some constraints have no JSON Schema equivalent at all, and a few map only approximately. Dropping those quietly would produce a document that promises *less* validation than the server performs — a client would send a payload the spec calls valid and get a `422` back. The mapper's fall-through says what happens instead:
+
+```php
+final class ConstraintSchemaMapper
+{
+ private function applyObject(MapperState $state, ValidationRule $rule): void
+ {
+ switch (true) {
+ // ... every recognised first-party rule object is matched above.
+ default:
+ // A third-party ValidationRule. Its class name is the only thing about it that is knowable
+ // without executing it, so that is what the extension records.
+ $state->unmapped($rule::class);
+ }
+ }
+}
+```
+
+Everything unrecognised — `after:now` and `before:now`, `exists:` and `unique:`, a bare Luhn or CUSIP check digit, a bound that names another field (`gte:other_field`) rather than a number, and any third-party `ValidationRule` — is recorded under `x-firefly-constraints`. So is a pattern the mapper could only approximate: `D` and `u` are dropped as genuine no-ops, but any other flag — `i` above all, which ECMA-262 has no inline syntax for inside a pattern string — cannot be carried across, so the pattern is still emitted (it is the closest true statement available) *and* the original rule is recorded, so a reader can see that the published pattern is stricter than the server's.
+
+Three properties, three outcomes:
+
+```json
+{
+ "deliverAfter": { "type": "string", "format": "date-time", "x-firefly-constraints": ["after:now"] },
+ "slug": { "type": "string", "pattern": "^[a-z]+$", "x-firefly-constraints": ["regex:/^[a-z]+$/i"] },
+ "account": { "type": "string", "format": "iban", "x-firefly-constraints": ["iban:checksum"] }
+}
+```
+
+Conforming tools ignore all three extensions and see a valid document. A human, or a generator you write yourself, can read the full truth.
+
+---
+
+## Responses: one problem component, and an error set that is derived
+
+Every operation ends at the same error component, and that component describes what LaraFly *actually* returns rather than what RFC 9457 describes in the abstract:
+
+```php
+final class ProblemSchema
+{
+ public const string MEDIA_TYPE = 'application/problem+json';
+
+ public static function response(): array
+ {
+ return [
+ 'description' => 'Error response in RFC 9457 problem+json form.',
+ 'content' => [self::MEDIA_TYPE => ['schema' => ['$ref' => self::REF]]],
+ ];
+ }
+}
+```
+
+The distinction matters because the two shapes differ. Chapter 4's `ErrorResponse::toArray()` emits `status`, `title`, `code`, `category` and `severity` unconditionally, then `detail`/`type`/`instance`/`traceId`/`timestamp` only when non-null, then `errors` only when non-empty. So `code`, `category`, `severity` and `errors` are Firefly members on top of the RFC's five; `type` is optional here where the RFC gives it a default; and `instance` carries a request *path* rather than a URI reference. Documenting the RFC's shape instead of this one would hand every generated client a decoder that silently drops the three members a caller actually branches on.
+
+The two enumerations are read straight off the kernel's own enums, so a case added in `firefly/kernel` appears in the spec on the next generation with no edit anywhere in `firefly/openapi`:
+
+```json
+{
+ "category": {
+ "type": "string",
+ "enum": ["business", "validation", "security", "infrastructure",
+ "external", "framework", "plugin", "internal"]
+ },
+ "severity": { "type": "string", "enum": ["info", "warning", "error", "critical"] }
+}
+```
+
+Which statuses an operation lists is **derived, not guessed**. Compare two real Lumen operations. `GET /api/v1/wallets/{id}/balance` takes one `string` path variable and no body:
+
+```json
+{
+ "200": { "description": "Successful response.",
+ "content": { "application/json": { "schema": { "type": "object" } } } },
+ "default": { "$ref": "#/components/responses/Problem" }
+}
+```
+
+`POST /api/v1/wallets/{id}/deposit` takes the same path variable plus a `#[Valid] #[RequestBody] AmountRequest`:
+
+```json
+{
+ "200": { "description": "Successful response.",
+ "content": { "application/json": { "schema": { "type": "object" } } } },
+ "400": { "$ref": "#/components/responses/Problem" },
+ "422": { "$ref": "#/components/responses/Problem" },
+ "default": { "$ref": "#/components/responses/Problem" }
+}
+```
+
+`400` appears exactly when the operation has something `ArgumentResolver` can reject *before* the controller runs — a body to decode and bind, an upload to validate, a required query or header the client may omit, or a non-`string` parameter that has to be coerced out of the wire's string. It is deliberately absent from `balance`: nothing about that request can fail binding, because a missing path segment does not match the route at all, and a documented `400` an endpoint cannot produce is noise a generated client turns into a dead error branch. `422` appears exactly when some binding carries `#[Valid]`, because that is the only way `BeanValidator` runs and so the only way Chapter 4's `ValidationException` can be thrown. And `default` covers everything the handler itself may raise — a `404` from a `ResourceNotFoundException`, a `409` from a `ConflictException`, a `403` from a denied `#[PreAuthorize]` — which cannot be enumerated from the route manifest without reading the controller's body, and which all render through the same `ProblemDetailsRenderer` anyway.
+
+A `204`, or a `void`/`never` return, gets no content at all, because emitting a content map for a status that carries no body is exactly what a strict client generator turns into a phantom return type. The success body of everything else is the subject of the next section.
+
+---
+
+## The success body: what an endpoint actually returns
+
+Look again at the two operations above. Both success responses are `{"type": "object"}` — an object with no members.
+
+That was every success response in every document this generator produced, and it is the one that matters most: a viewer renders it as a blank panel and `openapi-generator` turns it into `any`, so the single most useful sentence an API document contains — *here is what you get back* — was the one sentence missing, for every endpoint of every application.
+
+The reasoning had been that a `@return array{...}` is comment text nothing else in the framework treats as binding. That had already stopped being true. `RouteScanner` reads `@param list` to compile the table `ArgumentResolver` **hydrates** from, so a docblock type expression is exactly as binding as a declared type on the way *in*. And there is a stronger argument still: **PHPStan at level max already checks these expressions against the code on every build**, which is what makes reading them safe. An out-of-date `@return` is a failing gate, not a silent lie.
+
+So the success body now comes from three sources, most specific first:
+
+```php
+final class OrderController
+{
+ /**
+ * A page of orders.
+ *
+ * @return array{page: positive-int, size: positive-int, total: int, items: list}
+ */
+ #[GetMapping]
+ public function index(int $page, int $size): array
+ {
+ return $this->orders->page($page, $size);
+ }
+}
+```
+
+```json
+{
+ "type": "object",
+ "properties": {
+ "page": { "type": "integer", "minimum": 1 },
+ "size": { "type": "integer", "minimum": 1 },
+ "total": { "type": "integer" },
+ "items": { "type": "array", "items": { "$ref": "#/components/schemas/Order" } }
+ },
+ "required": ["page", "size", "total", "items"],
+ "additionalProperties": false
+}
+```
+
+1. The **`@return` type expression** — the only place a PHP `array` can say what is in it. Prose written after the type becomes the response `description`, which is the only response description anyone ever actually writes.
+2. The **declared return type** — a class becomes a component `$ref`, a backed enum its value set, a scalar itself.
+3. **Neither** — `type: object`, the old behaviour, kept as the *fallback* for a bare `array` return with nothing said about it. A `@return array` parses fine and means nothing, so it is treated as saying nothing rather than allowed to suppress what the declared type knew.
+
+### A parser, not another regular expression
+
+The package already had two regexes for the one shape it handled, `list` and `X[]`, and they cannot be extended to the rest. `array{items: list>}` needs balanced `<>` and `{}` and a comma that separates only at the outer level. That is a grammar, and a grammar wants a parser — about two hundred lines of recursive descent in `DocType`, against the four transitive dependencies `phpstan/phpdoc-parser` would put in every application that installs this package.
+
+| Written | Becomes |
+|---|---|
+| `list`, `Order[]`, `array` | `type: array` with `items: {$ref: Order}` |
+| `array` | `type: object` with `additionalProperties` |
+| `array{a: int, b?: string}` | an object, `required: [a]`, `additionalProperties: false` |
+| `array{a: int, ...}` | the same, open — the `...` is the only thing that lifts it |
+| `array{int, string}` | `prefixItems` — a tuple |
+| `'draft'\|'sent'` | `type: string` with `enum` |
+| `?Order` | `anyOf: [{$ref}, {type: null}]` |
+| `non-empty-string`, `positive-int` | `minLength: 1`, `minimum: 1` |
+| `never`, `callable`, an unresolvable name | **nothing** — the caller falls back to what it knew |
+
+A `?` on a shape **key** means "may be absent" and becomes `required`; a `?` on the **value** means "may be null". Conflating the two documents an omissible member as one a client must always send. Class names resolve through the imports of the file the expression was written in, because reflection does not expose a file's `use` statements — without that, only fully-qualified names would work, which is the one spelling nobody writes.
+
+### A returned class is built from its wire shape
+
+`ResponseSchemaFactory` is not `DtoSchemaFactory`, and the difference is the point. That factory derives members from the **constructor** and rules from the `ConstraintManifest` — the right two sources for a payload the server binds and validates, and the wrong two on the way out. A response is never validated, and its members are what `json_encode` emits.
+
+Which PHP spells two ways. A class implementing `JsonSerializable` serialises as whatever `jsonSerialize()` **returns**; everything else as its **public properties**. The skeleton's `App\Orders\Order` is the case that decides the design:
+
+```php
+final readonly class Order implements JsonSerializable
+{
+ /** @return array{id: int|null, customer: string, email: string, shipTo: Address, lines: list, total: float} */
+ public function jsonSerialize(): array
+ {
+ return [/* … */ 'total' => $this->total()];
+ }
+}
+```
+
+`total` is a derived **method**, not a property. Reflecting properties alone would publish five of the six members the API actually sends. The array shape states all six, PHPStan checks it against the method, and the generator reads it — delete the annotation and `total` silently disappears from the document while the API keeps sending it.
+
+A declared shape only wins when it says something: `@return array` on `jsonSerialize()` means "an object, members unknown", which is strictly less than the property list it would have suppressed, so it is ignored in favour of reflection.
+
+One rule inverts on the way out. **Nullability is not requiredness here.** A response member is present or absent, and `?int $id` is always *present* and sometimes null — so response members stay `required` and nullable ones widen their type. The request side's rule would have told every client to expect an absence that never happens.
+
+### `#[ApiResponse]` takes a type expression too
+
+```php
+final class ConsignmentController
+{
+ #[PostMapping(status: 201)]
+ #[ApiResponse(status: 409, description: 'That reference already exists.', type: Consignment::class)]
+ #[ApiResponse(status: 202, description: 'Accepted for later booking.', type: 'list')]
+ public function book(): array
+ {
+ return $this->consignments->book();
+ }
+}
+```
+
+`type` is a full expression, not only a class or a scalar name, and a short name resolves through the controller's own imports.
+
+---
+
+## `#[Controller]` HTML routes are not operations
+
+Chapter 4 introduced `#[RestController]` alongside its HTML sibling `#[Controller]`, and only the first is a JSON API. The generator honours that distinction by default:
+
+```php
+final class OpenApiGenerator
+{
+ private function excluded(RouteDescriptor $route): bool
+ {
+ if ($route->html && ! $this->properties->includeHtml) {
+ return true;
+ }
+
+ foreach ($this->properties->excludePathPrefixes as $prefix) {
+ if (str_starts_with($route->path, $prefix)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
+```
+
+A `#[Controller]` route renders a page. It is part of the application's HTTP surface, but it is not a JSON operation, and describing one as `application/json` would have a generator emit a typed client for a response that is a web page — the framework's own welcome page was in the spec exactly that way before this rule existed. Set `firefly.openapi.include-html` to `true` and the route is documented anyway, but honestly: the operation is then produced with `text/html` content and a `type: string` schema rather than a JSON schema that would be a lie a client generator faithfully acts on.
+
+The second half of that method is the blunt instrument for everything else: `firefly.openapi.exclude` is a CSV of path prefixes — `'/internal,/admin'` — for routes that are JSON but are nobody's public API.
+
+---
+
+## The viewer: three styles, and only one of them phones out
+
+`GET /openapi` renders a browser console over the document. Which console is `firefly.openapi.viewer.style`, and the choice is a supply-chain decision dressed up as a preference:
+
+| `style` | Ships from | Third-party request at page view? |
+|---|---|---|
+| `swagger` **(default)** | your own origin, out of the `swagger-api/swagger-ui` composer package | **no** |
+| `builtin` | inline in the response | **no** |
+| `cdn` | `cdn.jsdelivr.net` | **yes, on every view** |
+
+An unrecognised value falls back to `swagger` rather than rendering a blank page — a typo in a config file should cost you nothing.
+
+### Why the default is the official Swagger UI, from your own origin
+
+Every off-the-shelf viewer — Swagger UI, Redoc, Elements — is a bundled JavaScript application, and for years that left a PHP package exactly two options. Vendor a multi-megabyte minified bundle into the package's own git history, so every clone of every dependent project pays for it forever and the framework is pinned to a release train it cannot patch without cutting a release of its own. Or fetch it from a CDN on every page view, which is a supply-chain dependency and a data-protection question, and which renders *nothing at all* in the air-gapped and strict-CSP environments where an internal API console is most wanted.
+
+There is a third option, and this package takes it. `swagger-api/swagger-ui` publishes its `dist` on Packagist under Apache-2.0, so **composer** can fetch and pin it — it is a hard `require` of `firefly/openapi`, so the files are already on disk in `vendor/` by the time you first hit the route — and `SwaggerAssetAction` serves those files from the application's own origin:
+
+```php
+final class SwaggerAssetAction
+{
+ public function __construct(private readonly SwaggerAssets $assets) {}
+
+ public function __invoke(string $file): SymfonyResponse
+ {
+ $path = $this->assets->path($file);
+ $type = $this->assets->contentType($file);
+
+ if ($path === null || $type === null) {
+ return new Response('Not Found', 404, ['Content-Type' => 'text/plain; charset=UTF-8']);
+ }
+
+ $response = new BinaryFileResponse($path, 200, ['Content-Type' => $type]);
+ $response->setPublic();
+ $response->setMaxAge(31536000);
+ $response->setImmutable();
+ $response->setAutoEtag();
+
+ return $response;
+ }
+}
+```
+
+You get the console byte-for-byte as Swagger publishes it — the full feature set, deep linking, try-it-out, the OAuth2 redirect popup — with no CDN request, no npm step, and nothing in this repository's history that a `composer update` could not replace. The long cache header is safe precisely because the bytes are immutable for a pinned version: composer changes them only when the pinned version changes, and the ETag changes with them.
+
+!!! note "Path traversal is defended by a whitelist, not a sanitiser"
+ Only seven basenames are servable at all — `swagger-ui.css`, `swagger-ui-bundle.js`, `swagger-ui-standalone-preset.js`, `oauth2-redirect.html`, two favicons and `index.css` — and each resolved path is `realpath()`-checked to be inside the dist directory. The route itself constrains `{file}` to `[A-Za-z0-9._-]+`, so it cannot even *express* a traversal. Matching against a fixed list rather than scrubbing the input is the deliberate choice: a whitelist cannot be defeated by an encoding trick that a sanitiser missed. Anything else is a plain `text/plain` 404 — not problem+json, because the caller here is a browser fetching a stylesheet, not an API client.
+
+The dist directory is located by asking **Composer's own installed-versions metadata** for the package root, rather than walking up from `__DIR__`. The depth from `src/Web/` to `vendor/` differs between an installed package (`vendor/firefly/openapi/src/Web`) and this monorepo (`packages/openapi/src/Web`), so a relative walk would work in exactly one of them; the walk is kept only as a fallback for a runtime whose autoloader cannot answer.
+
+And if the distribution is genuinely absent — a stripped `vendor/`, a phar, a non-composer runtime — `render()` falls back rather than serving a page whose assets 404:
+
+```php
+final class ViewerPage
+{
+ public function render(string $specUrl, string $style, string $assetBase = ''): string
+ {
+ return match (true) {
+ $style === 'cdn' => $this->swaggerUiFromCdn($specUrl),
+ // Falling back rather than rendering a broken page: `swagger` is the DEFAULT, so an
+ // application that has not installed swagger-api/swagger-ui would otherwise get a console
+ // referencing assets that 404. The built-in reference needs nothing and is always available.
+ $style === 'swagger' && $this->assets->available() => $this->swaggerUi($specUrl, $assetBase),
+ default => $this->builtIn($specUrl),
+ };
+ }
+}
+```
+
+### What `builtin` is for
+
+A hand-written, dependency-free reference: one inline script, a few hundred bytes of CSS, one `fetch` of the spec route, and a palette that follows `prefers-color-scheme`. It does the two things a reader actually needs from a generated spec and that raw JSON does not give them — it groups operations by tag with verbs and paths visible at a glance, and it **resolves `$ref` pointers client-side**, so the reader sees a DTO's members rather than a pointer into `#/components/schemas`. Try-it-out, OAuth flows and code samples are deliberately absent; that is what `swagger` is for.
+
+Choose it when the deployment's rule is *no third-party JavaScript in the response at all*, rather than merely *no third-party host*.
+
+!!! note "Why that page is a nowdoc"
+ The built-in viewer embeds a JavaScript application, and a PHP **heredoc** interpolates variables. Every `$ref`, `$schema` and `$1` in that script was therefore read as a PHP variable — `$ref` silently became the empty string, and `$ref` resolution, the whole point of the page, stopped working. A nowdoc takes the script verbatim and the two real substitutions are made explicitly afterwards. It is the kind of bug that produces no error anywhere: the page renders, and simply shows pointers instead of schemas.
+
+### What `cdn` costs
+
+```php
+// config/firefly.php
+return [
+ 'openapi' => ['viewer' => ['style' => 'cdn']],
+];
+```
+
+Every page view then loads Swagger UI from `cdn.jsdelivr.net`. The version is pinned exactly, and **no Subresource Integrity hash is claimed** — deliberately: a hash the framework cannot verify at release time is security theatre, and a wrong one would simply break the page.
+
+Weigh the trade honestly. In exchange for a request to a third party on every view, a Content-Security-Policy that has to allow that host, and a console that renders nothing in an air-gapped deployment, you get… the same Swagger UI that `swagger` already served you from your own origin. The style is kept because it is what most tutorials show, and because some organisations genuinely prefer their bytes to come from a cache they already trust — not because it is the better default.
+
+!!! warning "`viewer.cdn` still wins over `viewer.style`"
+ `firefly.openapi.viewer.cdn` (default `false`) is the older boolean spelling of this option, from before `style` existed. It still **forces** the CDN page and overrides `style`, so an application that set it keeps the behaviour it configured rather than being silently moved onto a different console by a framework upgrade. Prefer `style` in new configuration; delete `cdn` when you adopt it.
+
+The viewer fetches the spec from the sibling route rather than having the document inlined into the page, so a regenerated spec shows up on a plain browser refresh, and so the two routes can be exposed independently — a deployment may well want the machine-readable document public and the console off, or the reverse. The spec URL is resolved through the `UrlGenerator` rather than concatenated, because an app mounted under a subdirectory or behind `APP_URL` would otherwise get a link that 404s from every page but the root, and a viewer whose only network call is wrong is a viewer that shows nothing at all.
+
+---
+
+## Configuration, and securing the surface
+
+Everything the document and its routes need lives under one config key:
+
+```php
+ [
+ 'enabled' => true, // master gate: off means every route is genuinely unrouted
+ 'path' => '/openapi.json', // spec route
+ 'viewer' => [
+ 'enabled' => true,
+ 'path' => '/openapi', // assets are mounted under {path}/assets/{file}
+ 'style' => 'swagger', // swagger (default) | builtin | cdn — see above
+ ],
+ 'title' => 'Lumen Wallet API',
+ 'version' => '1.0.0',
+ 'description' => '',
+ 'servers' => ['https://api.example.test'], // bare URLs or OpenAPI Server Objects
+ 'exclude' => '/internal,/admin', // CSV of path prefixes to leave out
+ 'include-html' => false, // document #[Controller] routes as text/html
+ ],
+];
+```
+
+`servers` accepts both spellings a real config file uses — a list of bare URL strings, and OpenAPI's own object form with a `description` — and an entry that is neither is dropped rather than emitted, because a Server Object with no `url` is invalid under the 3.1 schema and would poison an otherwise-good document.
+
+A public deployment is secured the way any other route is. Chapter 10's `HttpSecurity` rules — ahead, in Part III — cover the spec and viewer paths with no code edge at all, because `HttpSecurityFilter` is a global middleware and runs for natively-registered routes exactly as it runs for your controllers:
+
+```php
+ [
+ 'enabled' => true,
+ 'http' => [
+ 'enabled' => true,
+ 'rules' => [
+ ['pattern' => 'openapi', 'access' => 'hasRole:DEVELOPER'],
+ ['pattern' => 'openapi/*', 'access' => 'hasRole:DEVELOPER'],
+ ['pattern' => 'openapi.json', 'access' => 'hasRole:DEVELOPER'],
+ ],
+ ],
+ ],
+];
+```
+
+Note the three patterns. `openapi` alone does not match `openapi/assets/swagger-ui.css`, and `openapi.json` is a separate literal — a rule set that covers the console but not its assets produces an authenticated page whose stylesheet answers 401, which is a worse outcome than either extreme.
+
+The alternative, for a deployment that wants no documentation surface at all in production, is `enabled => false` plus a `firefly:openapi --output=` step in CI.
+
+---
+
+## Replacing one piece of the pipeline
+
+Every collaborator in the package — `OpenApiProperties`, `ConstraintSchemaMapper`, `DtoSchemaFactory`, `OperationFactory`, `OpenApiGenerator` and `ViewerPage` — is a `#[Bean]` behind `#[ConditionalOnMissingBean]`, the Chapter 2 mechanism. Teaching the generator about your own `ValidationRule` is therefore a short `#[Configuration]` in your application and never a fork:
+
+```php
+#[Configuration]
+final class ApiDocsConfiguration
+{
+ #[Bean]
+ public function constraintSchemaMapper(): ConstraintSchemaMapper
+ {
+ return new HouseConstraintSchemaMapper; // teaches the generator your own ValidationRules
+ }
+}
+```
+
+!!! note "The reflection here is real, and it is not on the request path"
+ `DtoSchemaFactory` reflects a DTO's constructor to learn its property types, which looks like a violation of the rule Chapter 13 will state in full: nothing on the cached request path reflects. It is not one. That reflection runs when `firefly:openapi` generates a file, or on a hit to the spec route — whose result the generator memoises for the life of the process — and never while dispatching an application request. It is the same category of work as `RouteScanner` and `ConstraintScanner`, both of which reflect at compile time only. The alternative, teaching `RouteScanner` to emit per-property types into every `RouteDescriptor`, was rejected because it would grow the compiled route manifest of *every* app for the benefit of one optional package.
+
+!!! laravel "Laravel parity"
+ Stock Laravel ships no OpenAPI support. The usual answers are a third-party package driven by its own annotation dialect (`zircote/swagger-php`'s `@OA\` blocks, or `vyuldashev/laravel-openapi`'s attribute classes) or a hand-maintained YAML file — both of which are a *second* description of the API, sitting beside the routes and the FormRequests that actually enforce it, and both of which drift. `firefly/openapi` has no dialect to learn because it has no second description: it reads the same `RouteManifest` the dispatcher dispatches from and the same `ConstraintManifest` the validator validates with. The closest analogue outside PHP is springdoc-openapi, and the closest thing in Laravel itself is `php artisan route:list` — accurate for the same reason, and for the same reason unable to tell you anything about a request body.
+
+---
+
+## What you learned {.recap}
+
+| Concept | What it does |
+|---|---|
+| `OpenApiGenerator` | Assembles an OpenAPI 3.1 document from `RouteManifest` + `ConstraintManifest`; memoised per instance, deterministically ordered so regenerations diff cleanly |
+| `firefly:openapi` | Writes the document to `--output=` or raw to stdout, making the spec a committable build artifact a CI job can diff |
+| `OpenApiRouteRegistrar` | Mounts `/openapi.json`, `/openapi` and `/openapi/assets/{file}` natively from a `BootPass` — configurable paths an attribute route could never have, and no self-documentation |
+| `OperationFactory` | Maps each binding `kind` — `path`/`query`/`header`/`file`/`body` — to its OpenAPI shape; `service` bindings never appear |
+| `SchemaRegistry` | One component per DTO, reached by `$ref`: no duplicate generated types, and a reserved name closes a recursive `$ref` cycle |
+| `DtoSchemaFactory` | Merges declared constructor types with compiled constraints; emits no `additionalProperties: false`, because the server ignores extra keys |
+| `ConstraintSchemaMapper` | Maps the compiled rule list — not the attributes — to keywords; first writer wins, so `#[Min(1)] int` stays `integer` |
+| `x-firefly-constraints` | Records what JSON Schema cannot state (`after:now`, a checksum, a flagged PCRE, a third-party rule) instead of dropping it |
+| `ProblemSchema` | The one shared `application/problem+json` response; documents Firefly's `code`/`category`/`severity`/`errors`, with the enums read off the kernel's own cases |
+| Derived error set | `400` only when something is rejectable before the controller runs, `422` only under `#[Valid]`, `default` always |
+| `DocType` | Compiles a PHPDoc type expression to a JSON Schema fragment — shapes, generics, tuples, literal unions, PHPStan pseudo-types — and returns *nothing* rather than guessing when it cannot read one |
+| `ResponseSchemaFactory` | Builds a returned class from its WIRE shape: `jsonSerialize()`'s declared `@return` when there is one, public properties otherwise. Response members stay `required` and nullable ones widen their type |
+| `#[ApiResponse(type:)]` | A full type expression (`'list'`), resolved through the controller's own imports |
+| `$route->html` | `#[Controller]` HTML routes are excluded by default; `firefly.openapi.include-html` documents them as `text/html`, never as JSON |
+| `firefly.openapi.viewer.style` | `swagger` (default) \| `builtin` \| `cdn`. Only `cdn` makes a third-party request at page view; an unrecognised value falls back to `swagger` |
+| `SwaggerAssets` | Serves the OFFICIAL Swagger UI from your own origin out of the `swagger-api/swagger-ui` composer package — seven whitelisted basenames, each `realpath()`-checked inside the dist directory |
+| `ViewerPage::render()` | Falls back to `builtin` when the Swagger dist is absent, rather than rendering a console whose assets 404 |
+
+---
+
+## Try it yourself {.exercises}
+
+1. **Generate Lumen's document and read it.** Run `php artisan firefly:openapi --output=openapi.json` in the sample, then open `/openapi` in a browser. Find `walletBalance` and confirm it has no `400` response, then find `walletDeposit` and confirm it has both a `400` and a `422` — and satisfy yourself, from this chapter's rules, why the two differ.
+2. **Make the spec a CI gate.** Commit the generated file, then add a job that regenerates it and runs `git diff --exit-code` over it. Change a DTO — add a `#[Size(max: 32)]` to `OpenWalletRequest::$owner_id` — and watch the job fail with a diff that names the exact schema keyword that changed.
+3. **Prove the default console makes no outbound request.** Open `/openapi` in the sample with the browser's network panel recording, and confirm every request is same-origin: the page, `openapi/assets/swagger-ui.css`, the two bundles, and `openapi.json`. Then set `firefly.openapi.viewer.style` to `cdn`, reload, and watch `cdn.jsdelivr.net` appear in the same panel — that request is the entire difference, and it is what a strict CSP or an air-gapped host would block.
+4. **Delete an annotation and watch the document lose a member.** In the skeleton, remove the `@return array{...}` from `App\Orders\Order::jsonSerialize()`, regenerate, and find `total` gone from the `Order` schema while `GET /orders/1` still returns it. Put it back, then change `total: float` to `total: string` and run PHPStan: the gate that keeps the document honest is the one that fails.
+5. **Watch a constraint fall through to the extension.** Add `#[Future]` to a `string` property on a request DTO, regenerate, and find the property's `x-firefly-constraints` array carrying `after:now` beside a perfectly ordinary `format: date-time`. Then add `#[Pattern('/^[a-z]+$/i')]` to another property and compare: the pattern *is* published, and the original rule is recorded beside it because the `i` flag could not survive the translation.
diff --git a/book/src/11-observability-actuator.md b/book/src/11-observability-actuator.md
index a4518b4..a4d92ae 100644
--- a/book/src/11-observability-actuator.md
+++ b/book/src/11-observability-actuator.md
@@ -2,7 +2,7 @@
# Observability: Health, Metrics, and the Actuator {.chtitle}
-By the end of this chapter you will know `firefly/actuator`'s `HealthIndicator` SPI and the built-in `Ping`/`DiskSpace`/`Db` indicators, how `HealthEndpoint` aggregates them into a single `/actuator/health` response — and how a probe **group** (the mechanism behind "liveness" and "readiness") is nothing more than a named, configured subset of indicators, how the whole management surface is **unexposed by default** so a forgotten endpoint fails closed as a 404 rather than an information leak, and `firefly/observability`'s pure-PHP `MeterRegistry`, its locale-safe Prometheus exporter, and the exact `#[Order(500)]` precedence trick — the same one Chapter 10 showed you for security — that lets `MeterRegistryCqrsMetrics` replace the CQRS bus's `NoOpCqrsMetrics` with no code change to `firefly/cqrs` at all.
+By the end of this chapter you will know `firefly/actuator`'s `HealthIndicator` SPI and the built-in `Ping`/`DiskSpace`/`Db` indicators, how `HealthEndpoint` aggregates them into a single `/actuator/health` response — and how a probe **group** (the mechanism behind "liveness" and "readiness") is nothing more than a named, configured subset of indicators, how the whole management surface is **unexposed by default** so a forgotten endpoint fails closed as a 404 rather than an information leak, and `firefly/observability`'s pure-PHP `MeterRegistry`, its locale-safe Prometheus exporter, and the exact `#[Order(500)]` precedence trick — the same one Chapter 10 showed you for security — that lets `MeterRegistryCqrsMetrics` replace the CQRS bus's `NoOpCqrsMetrics` with no code change to `firefly/cqrs` at all. The chapter closes on `firefly/admin`, the server-rendered browser dashboard over those same endpoints — a drawn **bean graph** that resolves every constructor dependency through the interface it is wired by and reports the cycles a boot would otherwise die on with no message. It reads those endpoints **in-process**, so it renders pages the JSON surface deliberately keeps unexposed, which makes its own URL the entire security boundary and its default (`app.debug`) the most important line in the package.
!!! note "New term: actuator"
An **actuator** is a management endpoint that reports on the *running process itself* — is it healthy, what did it boot with, how fast are its requests — rather than on the business domain the process serves. The term and the shape both come from Spring Boot Actuator; `firefly/actuator` is a first-party, dependency-light PHP analogue: framework endpoints mounted directly on the same Illuminate `Router` your own controllers use, not a separate admin process.
@@ -378,7 +378,7 @@ return [
`firefly/actuator`'s own `composer.json` has no dependency on `firefly/security` at all — securing the actuator this way is **pure configuration**, drawing on the same deny-by-default URL DSL Chapter 10 already taught you, with no new mechanism to learn.
!!! warning "Every other management endpoint is a standalone endpoint, not an `/info` sub-key"
- `/actuator/info` genuinely has exactly two fragments — `app` (from `AppInfoContributor`, read from `firefly.management.info.app.*`) and `build` (from `BuildInfoContributor`, reading a JSON file at `firefly.management.info.build.path`). `env`, `beans`, `conditions`, `mappings`, `loggers`, and `scheduledtasks` are each their **own** `ActuatorEndpoint`, mounted at their own `/actuator/{id}` — not nested under `/info`. `firefly:about` (Chapter 13) renders several of these together at the terminal, which is a convenience of that one command, not evidence they share a route.
+ `/actuator/info` genuinely has exactly three fragments — `runtime` (from `RuntimeInfoContributor`, registered by default, which is why a freshly generated application already answers something: PHP version/SAPI/OPcache, Laravel version, LaraFly version, current and peak memory; turn it off with `firefly.management.info.runtime.enabled = false`), `app` (from `AppInfoContributor`, read from `firefly.management.info.app.*`) and `build` (from `BuildInfoContributor`, reading a JSON file at `firefly.management.info.build.path`). `env`, `beans`, `conditions`, `mappings`, `loggers`, `scheduledtasks`, `configprops`, and `caches` are each their **own** `ActuatorEndpoint`, mounted at their own `/actuator/{id}` — not nested under `/info`. `firefly:about` (Chapter 13) renders several of these together at the terminal, which is a convenience of that one command, not evidence they share a route.
---
@@ -648,8 +648,355 @@ Every observability bean gates on the **same** property, `firefly.observability.
Finally, a `Tracer` port rounds out the package — a minimal `trace(string $name, callable $callback): mixed` span abstraction, shipped today only as `NoOpTracer` (it simply runs the callback), written against the interface so an OpenTelemetry-backed adapter can drop in later with zero call-site changes — the identical "port now, adapter later" shape you have now seen for `CqrsMetrics` itself.
+---
+
+## The admin dashboard: `firefly/admin`
+
+Everything so far in this chapter is JSON, and JSON is the right shape for a load balancer, a Kubernetes probe and a Prometheus scraper. It is not the right shape for a person at 3am who wants to know whether this process compiled its manifests, which auto-configuration backed off, and what `firefly.data.*` actually resolved to. `firefly/admin` is the package for that person: a server-rendered browser dashboard over the very same actuator endpoints, in the spirit of Spring Boot Admin. It arrives with `firefly/firefly` like the rest of the family, so a skeleton project already has it — and, as with `firefly/actuator`, having it installed is not the same as having it switched on. Add it directly only if you took the packages à la carte:
+
+```bash
+composer require firefly/admin
+```
+
+Then open `/firefly`. There is no npm step at install time and no CDN at request time — the views are plain Blade with inline CSS and system fonts, because a Composer package cannot assume npm has run, and a dashboard that needs the network is useless in exactly the isolated environments where you most want to look at one.
+
+Most pages are a view over one endpoint's payload; four read the container instead. The menu groups them the way an operator thinks rather than the way the packages are laid out — what is it doing right now, what did it wire at boot, what is its data, and how is it configured — because a flat list of seventeen links is a worse menu than four short ones:
+
+| Group | Page | Reads | Answers |
+|---|---|---|---|
+| Runtime | Overview | several | Is it healthy, what is it doing, and what did it wire? |
+| Runtime | Health | `health` | Every indicator this process registered, with its own status and details |
+| Runtime | Metrics | `metrics` | Counters, timers and gauges, with their current measurements |
+| Runtime | HTTP traffic | `httpexchanges` | The most recent requests this application served |
+| Wiring | Beans | `beans` | Every bean the container registered, with the stereotype that declared it |
+| Wiring | Bean graph | `beans` | How your beans depend on one another, resolved through the interfaces they are wired by |
+| Wiring | Conditions | `conditions` | Which auto-configurations applied, and which backed off because you supplied your own |
+| Wiring | Routes | `mappings` | The compiled route table the dispatcher serves from |
+| Wiring | Scheduled | `scheduledtasks` | Methods registered by `#[Scheduled]`, with the cron or interval that drives them |
+| Configuration | Environment | `env` | Resolved `firefly.*` configuration, with secrets masked |
+| Configuration | Config properties | `configprops` | Every `#[ConfigProperties]` DTO the application bound, with the values it resolved |
+| Configuration | Caches | `caches` | The cache stores this application has configured |
+| Configuration | Loggers | `loggers` | Log channels and their levels, with a control to change one |
+
+A page whose endpoint is not registered in *this* process — or is switched off — is **hidden from the menu** rather than offered as a link that lands on an apology. That matters because the actuator's endpoints are conditional: `metrics` disappears when `firefly.observability.metrics.enabled` is false, and several others exist only if the package that contributes them is installed. The menu has to be built from what this process actually registered, so it is.
+
+---
+
+### It reads endpoints in-process, not over HTTP
+
+The dashboard holds the `ActuatorRegistry` and invokes each `ActuatorEndpoint` bean directly:
+
+```php
+final readonly class AdminEndpointReader
+{
+ public function __construct(
+ private ActuatorRegistry $registry,
+ private Config $config,
+ private ?Container $container = null,
+ ) {}
+
+ /** The endpoint ids that are registered AND not switched off, in registration order. */
+ public function available(): array
+ {
+ $ids = [];
+ foreach ($this->registry->all() as $id => $endpoint) {
+ if ($endpoint->enabled() && $this->config->bool("firefly.management.endpoint.{$id}.enabled", true)) {
+ $ids[] = $id;
+ }
+ }
+
+ return $ids;
+ }
+
+ public function read(string $id, array $subPath = [], array $query = []): ?array
+ {
+ $endpoint = $this->registry->get($id);
+ if ($endpoint === null || ! $this->has($id)) {
+ return null;
+ }
+
+ try {
+ $response = $endpoint->handle(new EndpointRequest('GET', $subPath, $query));
+ } catch (Throwable) {
+ return null;
+ }
+
+ return $response === null || is_string($response->body) ? null : $response->body;
+ }
+}
+```
+
+Look at what is **not** in that method: any mention of `ExposureModel`. That is the single most important thing to understand about this package, and it is deliberate. `firefly.management.endpoints.web.exposure.include` defaults to `health,info`, so fetching `/actuator/beans` or `/actuator/env` over HTTP would 404 — as the whole first half of this chapter insisted it should. The dashboard needs none of that. It renders what the process already knows, in-process, so **it shows you pages the HTTP surface deliberately does not expose**, and the JSON surface stays secure-by-default. Exposing `beans`, `conditions` and `env` to every anonymous caller just so a browser could read them would be exactly the wrong trade.
+
+The per-endpoint kill switch *is* honoured, and the asymmetry is the point: `firefly.management.endpoint.{id}.enabled` means "this endpoint is off", which is a statement about the endpoint itself; `exposure.include` means "this endpoint is unpublished", which is a statement about the HTTP surface. The dashboard is not the HTTP surface.
+
+A throwing endpoint is caught and reported as `null` rather than allowed to take the page down with it — the same fail-safe discipline `HealthEndpoint::readFailSafe()` applies to indicators, for the same reason: one broken contributor should degrade its own panel, not the dashboard.
+
+!!! note "Health details are read from the contributor registry, not through the endpoint"
+ `show-details` defaults to `never`, and that default is right — it stops an anonymous HTTP caller learning your database host from a failed connection. Applying that HTTP disclosure policy to the dashboard, though, produced a Health panel whose entire content was an apology telling the operator to go and change a config key. The dashboard reads `HealthContributorRegistry` directly instead, calling each indicator in isolation so one that throws is reported DOWN with its reason and nothing else is affected.
+
+---
+
+### The bean graph
+
+Most of the pages are tables. Two draw a picture — this one, and the [entity map](#walking-the-model-relations-filters-and-a-map) later in the chapter — and this is the one that pays for the package on the day something is wired wrongly.
+
+`/actuator/beans` tells you *which* beans exist. It cannot tell you what each one is **wired to**, which is what you actually want when a `#[ConditionalOnMissingBean]` did not fire the way you expected, when an eager singleton cycle has hung a boot with no message, or when you are trying to work out what a package you just installed attached itself to. `/firefly/graph` answers that, as a layered SVG diagram plus a filterable relations table.
+
+Nothing is reflected to build it. `ComponentScanner` already records, at **scan** time, the class and interface types each component's constructor asks for, and that list rides the compiled manifest exactly like every other scanned fact (abridged):
+
+```php
+final class ComponentDescriptor
+{
+ public function __construct(
+ public string $class,
+ public string $stereotype,
+ public array $interfaces,
+ /**
+ * The class types this component's constructor asks for — the edges of the bean graph.
+ *
+ * Recorded at scan time, where reflection is already sanctioned, because the alternative is
+ * reflecting at request time to answer "what depends on what", which the reflection-free boot
+ * contract forbids. Only CLASS and INTERFACE types are kept: a scalar or a builtin is
+ * configuration, not a wiring edge, and putting it in the graph would drown the edges that matter.
+ */
+ public array $dependencies = [],
+ ) {}
+}
+```
+
+That last sentence is a design decision worth pausing on. A constructor parameter typed `string $name` is configuration; drawing it as an edge would bury the relationships that matter under `string`/`int` noise. A **nullable or defaulted class** parameter *is* kept, because an optional collaborator is still a relationship.
+
+#### Three kinds of node, and why the first version was nearly empty
+
+Before the edges, the nodes — because the first version of this page got them wrong in a way worth learning from. A LaraFly application has **three kinds of bean**, and all three have to be nodes:
+
+| Kind | What it is | Where it comes from |
+|---|---|---|
+| `component` | A scanned `#[Component]`/`#[Service]`/`#[Repository]`/`#[RestController]`/`#[Configuration]` class | The beans catalogue |
+| `bean` | A value **produced by a `#[Bean]` factory method** on a `#[Configuration]` | The catalogue's `produces` rows |
+| `config` | A `#[ConfigProperties]` DTO bound from configuration | The `configprops` endpoint |
+
+Only the first kind was a node to begin with, and the consequence was not a cosmetic one. A framework's wiring lives almost entirely in the second kind: an auto-configuration is a `#[Configuration]` whose `#[Bean]` methods produce `MeterRegistry`, `TransactionTemplate`, `AggregateTracker` and the rest. With only declaring classes as nodes, every edge pointing at one of those products pointed at a node that did not exist. Measured on a stock skeleton: **42 nodes, 41 `#[Bean]` products missing, 21 dangling dependencies, and exactly one edge drawn.** The page was not showing a sparse graph — it was structurally incapable of showing framework wiring at all.
+
+The third kind is the same mistake in miniature. A `#[ConfigProperties]` DTO is bound and injectable, but it is neither scanned as a component nor produced by a factory, so nothing in the beans catalogue can see it: `App\GreetingProperties` turned up as an *unresolved dependency* of `GreetingService` rather than as the bean it is. That is why the page reads the `configprops` endpoint alongside the catalogue.
+
+So there are two kinds of edge, too, and they say different things:
+
+| Edge | From → to | Meaning |
+|---|---|---|
+| `injects` | A bean → something it declared a dependency on | The consumer asked for it; the container satisfies it |
+| `produces` | A `#[Configuration]` → the value one of its `#[Bean]` methods returns | This class is where that bean comes from |
+
+`injects` edges are collected from a component's **constructor** parameters *and* from every `#[Bean]` **factory method's** parameters — the product depends on whatever its factory asked for. That union is the wiring; constructors alone are a fraction of it.
+
+One subtlety about identity. A `#[Bean]` product is normally identified by the **type it produces**, because that is the key the container binds and the key every consumer asks for. But when two factory methods produce the same type — the shape that Chapter 2's `#[Primary]`/`#[Qualifier]` rules exist to disambiguate — the type alone would collapse them into a single node and hide exactly the ambiguity you opened the page to see. So each competitor gets `Declaring::method()` as its own id and the bare type resolves to the first of them, mirroring the container, where the type key aliases the winner while every candidate stays reachable by name.
+
+#### The hard part is not drawing, it is resolving
+
+A constructor asks for a **type**, and that type is very often an interface — `EventPublisher`, `HealthIndicator`, `Cache` — while the bean that satisfies it is a concrete class that merely implements it. An edge list built naively from constructor types therefore points at nodes that do not exist, and the graph comes out as a field of disconnected dots. Ask yourself what `WalletService`'s dependency on `WalletRepository` should draw an arrow *to*: not to the port, which is an interface with no bean of its own, but to `EloquentWalletRepository`, which is the thing that will actually be constructed.
+
+So every dependency is resolved through an interface index before it becomes an edge:
+
+```php
+foreach ($rows as $class => $row) {
+ foreach ($row['dependencies'] as $dependency) {
+ $target = isset($rows[$dependency]) ? $dependency : ($byInterface[$dependency] ?? null);
+
+ if ($target === null || $target === $class) {
+ // A type nothing in the container provides: a framework contract satisfied by a binding
+ // rather than a bean, or a class the scan never saw. Reported, not silently dropped —
+ // "why is my bean not in the graph" is exactly the question this page has to answer.
+ if ($target === null) {
+ $unresolved[] = $dependency;
+ }
+
+ continue;
+ }
+
+ $edges[] = ['from' => $class, 'to' => $target, 'via' => $target === $dependency ? null : $dependency];
+ }
+}
+```
+
+The `via` member is the honesty in that loop. When the edge went through an interface, the diagram marks it and the Relations table's **Wired by** column names the interface, so a reader can see the indirection rather than being quietly shown a relationship they never wrote. When the constructor named the concrete class, the column just says `class`.
+
+The index is built in catalogue order and **first implementor wins**, deterministically — the catalogue is emitted in scan order, so the same application always draws the same graph rather than reshuffling between machines. An interface with several implementors is a real ambiguity that the container resolves with `#[Primary]`/`#[Qualifier]`, and the graph says so by listing the edge as `via` rather than pretending the choice was obvious.
+
+#### Layers, cycles, and the node ceiling
+
+Levels come from a **longest-path** walk over the resolved edges: a node's depth is one more than the deepest thing it depends on, and the levels are then flipped so level 0 holds the things nothing depends on. The effect is that a node always sits below everything that depends on it, arrows read consistently downward, and the eye can follow a chain from a controller to the repository at the bottom of it. The view only positions; the levels come from the model.
+
+Depth is memoised and the walk carries its own visited set, so a cycle terminates instead of recursing forever — and the edge that closed it is *reported*:
+
+```php
+foreach ($out[$node] ?? [] as $next) {
+ if (isset($path[$next])) {
+ $cycles[] = ['from' => $node, 'to' => $next];
+
+ continue;
+ }
+ $deepest = max($deepest, $walk($next, $path) + 1);
+}
+```
+
+That reporting is worth more than it looks. The container has no cycle detection of its own, so a cycle among eager singletons does not produce a helpful error — it exhausts memory at boot. A page that names the two classes involved turns "the app died with no message" into a five-second diagnosis, and the panel's own advice is the right one: break one of these edges, usually by injecting an interface and letting the other side depend on that.
+
+Two limits are stated in the page rather than hidden:
+
+* **Past `firefly.admin.graph.max-nodes` — 220 by default — the diagram is suppressed** and the Relations table below carries the same information as a filterable list. A diagram past a couple of hundred nodes is a hairball, not something a person can read, and rendering one anyway would be a worse answer than declining to. It is a config key rather than a constant because "unreadable" depends on the screen and the application.
+* **"Provided outside the container" is not a warning.** Those chips are constructor types satisfied by a Laravel container binding rather than a scanned bean — the `Request`, the config repository, a connection. They are listed rather than silently dropped precisely because *"why is my bean not in the graph"* is the question the page has to answer. A type appearing there that you expected to be a bean of *yours* means your scan did not see it, and `firefly.scan.paths` is the first thing to check.
+
+!!! tip "Read it next to the Conditions page"
+ The two answer complementary halves of every auto-configuration surprise. **Conditions** says *whether* a framework bean was registered or backed off, and on which condition. **The graph** says what the bean that did win is wired to, and through which interface. An `EventPublisher` edge pointing at `InMemoryEventPublisher` when you configured `firefly.eda.provider=rabbitmq` is one glance on the graph; Conditions then names the `#[ConditionalOnProperty]` that did not match.
+
+!!! note "What the graph still does not decide for you"
+ Two limits are worth knowing, and neither is a gap in the data. **`#[Primary]`/`#[Qualifier]` do not steer the index** — first writer in scan order wins, both for an interface with several implementors and for the bare type key of a contested `#[Bean]`. Every competitor still gets its own node and the edge is marked `via`, so the ambiguity is visible on the page, but the drawn target may not be the one the container resolves. And **an unresolved type is reported, never explained**: the page can tell you a type is provided outside the container, but not *which* binding provides it, because a Laravel container binding carries no descriptor to read.
+
+---
+
+### The access model is the whole security boundary
+
+Because the dashboard bypasses exposure, its own URL is the only thing standing in front of `beans`, `env` and `conditions`. That is why it must not be on by default in production, and why the enable flag is written the way it is:
+
+```php
+final readonly class AdminSettings
+{
+ public function __construct(
+ public bool $enabled,
+ public string $basePath,
+ public string $title,
+ // ... plus the presentation options: refreshSeconds, theme, graphMaxNodes, excludedPages.
+ ) {}
+
+ public static function fromConfig(Config $config): self
+ {
+ $base = trim($config->string('firefly.admin.base-path', '/firefly'), '/');
+
+ return new self(
+ enabled: $config->bool('firefly.admin.enabled', $config->bool('app.debug', false)),
+ basePath: $base === '' ? 'firefly' : $base,
+ title: $config->string('firefly.admin.title', $config->string('app.name', 'LaraFly')),
+ // ... firefly.admin.refresh-seconds (10, floored at 2), .theme (auto|light|dark),
+ // .graph.max-nodes (220) and .pages.exclude ('') are read here too.
+ );
+ }
+}
+```
+
+`firefly.admin.enabled` **defaults to the value of `app.debug`**. The reasoning is that an application already running with debug on is already serving stack traces to whoever asks and is a development environment by definition, so a dashboard there discloses nothing that was not already disclosed. An application with debug off has made the opposite statement about itself, and must opt in explicitly. Setting the key always wins over the debug default, in both directions — you can turn the dashboard off in a debug environment, and on in a production one.
+
+!!! warning "Turning it on outside debug is only half the job"
+ `firefly.admin.enabled = true` with `app.debug = false` mounts a dashboard that renders your bean graph, your resolved configuration and your route table at a known URL, to anyone who can reach it. The dashboard ships **no authentication of its own** — it has no code dependency on `firefly/security` at all, exactly as `firefly/actuator` does not. An application that turns it on outside debug **must put the route behind its own auth middleware**.
+
+Chapter 10's `HttpSecurity` rules do that as pure configuration, the same way this chapter already secured the actuator — here is a deployment that opts in and locks the route down in one file:
+
+```php
+ [
+ 'enabled' => true, // explicit: this deployment wants the dashboard with app.debug off
+ 'base-path' => '/firefly',
+ ],
+ 'security' => [
+ 'enabled' => true,
+ 'http' => [
+ 'enabled' => true,
+ 'rules' => [
+ ['pattern' => 'firefly', 'access' => 'hasRole:ADMIN'],
+ ['pattern' => 'firefly/*', 'access' => 'hasRole:ADMIN'],
+ ],
+ ],
+ ],
+];
+```
+
+`AdminRouteRegistrar` mounts the two routes — an index and a `GET|POST {base}/{page}` catch-all — as a `BootPass` at `WiringPasses`, order **60**, one step after `ActuatorRouteRegistrar`'s 50, because it reads the registry that pass populates. They are registered natively on the Illuminate `Router` for the same reason the actuator's and the OpenAPI package's routes are: `firefly.admin.base-path` has to be settable per application, and an attribute route bakes its literal path into a compiled `RouteDescriptor`. When the dashboard is disabled the pass registers *nothing at all* — there is no route to guess at and no handler to reach.
+
+It also backs off silently in one more case that is easy to miss. Blade is required to render the dashboard and is not a dependency of the package, so a JSON-only deployment with no view factory bound gets no routes rather than routes that would fatal on first request; the JSON actuator remains the management surface there.
+
+!!! warning "Three things the dashboard can only show you about *this* process"
+ Under PHP-FPM every request is a different process, and three pages inherit that. **Changing a log level** calls the same endpoint `POST /actuator/loggers/{name}` does, which mutates the current process's Monolog handlers — the next request is a different process, so change `logging.channels` for anything that must persist. **Metrics** are only as durable as the registry: the default `SimpleMeterRegistry` keeps meters in process memory, so the dashboard sees only its own request unless `firefly.observability.metrics.store` points at a cache store. And **health details** stay hidden on the JSON `/actuator/health` response until `firefly.management.endpoint.health.show-details` is `always`, even though the dashboard's own Health page reads the indicators directly.
+
+### The data browser, and why it does not inherit that default
+
+`firefly/admin` ships one more surface, and it is the only one in this chapter whose gate is written differently from every other gate you have seen. It is a Django-admin-style **database browser** over the data layer of Chapter 5 — listing, detail, search, sorting and paging over your own repositories — reached through a single `DataBrowser` that `DataBrowser::forContainer($container)` assembles from the application container.
+
+It discovers what to browse the same way the rest of the dashboard discovers everything — from the compiled catalogue. **Every bean whose scan-time interface list contains `CrudRepository` is a browsable resource.** Nothing is registered, nothing is declared: a repository you write is browsable the moment the container has it, and one you delete stops being browsable without anyone editing a list. Each row of `BeansCatalog` already carries the full interface closure `ComponentScanner` recorded with `class_implements()`, so "is this bean a repository, and does it also page?" is two `in_array()` calls over data the process already holds — no reflection, and, decisively, no chance of offering a resource the container never registered.
+
+Now the gate:
+
+```php
+final readonly class DataBrowserSettings
+{
+ public static function fromConfig(Config $config): self
+ {
+ $max = min(self::PAGE_SIZE_CEILING, max(1, $config->int('firefly.admin.data.max-page-size', 200)));
+
+ return new self(
+ enabled: $config->bool('firefly.admin.data.enabled', false),
+ writable: $config->bool('firefly.admin.data.writable', false),
+ pageSize: min($max, max(1, $config->int('firefly.admin.data.page-size', 25))),
+ maxPageSize: $max,
+ excluded: self::csv($config->string('firefly.admin.data.exclude', '')),
+ );
+ }
+
+ /** Writing requires BOTH gates. */
+ public function canWrite(): bool
+ {
+ return $this->enabled && $this->writable;
+ }
+}
+```
+
+Look at the two defaults, and compare them with `AdminSettings`'s `$config->bool('app.debug', false)` a few pages up. The dashboard follows `app.debug`, and the argument for that was sound **for what the dashboard shows**: beans, conditions, mappings and resolved configuration are facts about the *application*, and an application already serving stack traces has already published facts of that kind.
+
+This page shows facts about the application's **users**. That is a categorically bigger disclosure, and the mistakes that expose it are the ordinary ones that cost nothing today: a debug flag left on in a staging environment that shares a database with production, a `.env` copied to a box that was supposed to be internal, a laptop tunnelled for a demo. Each becomes a customer-record disclosure the moment a data browser is wired to `app.debug`. So the gate is separate, explicit and off — **`app.debug` cannot switch it on, and neither can `firefly.admin.enabled`.** All three must be true.
+
+Writes then need a *second* key, and it is ineffective on its own. Reading the wrong row is a disclosure; deleting it is data loss with no undo, from a form, over a session that may be nothing more than "debug was on". Turning on the browser is a decision about **visibility**; turning on writes is a decision about **custody**. Collapse them into one key and the operator who wanted to look at a table has also armed the delete button.
+
+!!! warning "Create exists for Eloquent, and is refused for everything else"
+ The browser had no `create()` at all for a while, and the argument was half right. A generic create form over an arbitrary entity is a promise it cannot keep, and Chapter 6 is why: **an aggregate's constructor is where its invariants live.** An `Order` that must have at least one line, a `Wallet` whose balance starts at zero in the currency it was opened in, a value object that rejects a malformed IBAN — a form built from a column list knows none of them. There are only two ways to build such a row: call the constructor, which needs arguments the form cannot supply in the right types or the right order; or write the columns straight to the table, which produces a row the domain model considers impossible. The second is what a "just insert the columns" implementation does, and it is *worse than having no button*, because it looks like it worked. **That case is still refused, by name.**
+
+ It was never true for an **Eloquent model**, which is constructed empty and filled by attribute — precisely what `update()` has always done to a row that exists. Create was refusing on a risk update was already taking, and the inconsistency cost every application a CRUD surface that stopped at RUD. So it is offered for an Eloquent-backed resource under the same two switches, with the identifier and any masked column *omitted from the form* rather than disabled in it: a field the browser would refuse to write should not appear to accept.
+
+Two more decisions are worth carrying out of this section, because both are the sort of thing that reads as a detail and is not.
+
+**The identifier and any masked secret are refused as update targets** — and refused twice, once so the view can render the field read-only and again in the write path, so a hand-crafted POST cannot reach what the form would not offer. Re-keying a row from a generic form is not an edit, it is a different row, and the foreign keys pointing at the old value do not follow. A secret's *displayed* value is `******`, so round-tripping a rendered form would write the mask over the real credential — a data-loss bug the masking itself created. Secrets are excluded from **search** for a related reason: a box that answers "yes, some row's `api_token` starts with `sk_live_9`" is an oracle an operator can walk one character at a time.
+
+**No error text the page renders is ever an exception message.** Laravel's `QueryException` stringifies the failing SQL *and its bindings* into `getMessage()`, so echoing it would publish the schema and the bound values — which on a search over a users table is the operator's own query, and on a detail lookup is a primary key. Every reason is a fixed sentence composed in the browser layer, plus at most the exception's class name; the message stays in the exception, where the log can have it. This is also why nothing in the layer throws at its caller: reads answer with a listing carrying a reason, writes with one of four outcomes (`Done`, `Refused`, `NotFound`, `Failed`), and a view rendering an admin page never has to be exception-safe to stay on its feet.
+
+!!! note "The listing path you get depends on the interface you implemented"
+ A `PagingAndSortingRepository` is paged **in the database**: the repository does the offset, the limit, the `ORDER BY` and the `COUNT`, and the cost is independent of table size. A plain `CrudRepository` cannot express any of that, so the browser calls `findAll()`, sorts and slices **in PHP**, and throws away all but 25 rows — which on ten thousand rows is a slow page and on ten million is an out-of-memory that kills the worker, on the *first* click. The interface has no limit, no offset and no count-with-predicate, so the honest options were "refuse to browse repositories that cannot page" or "browse them and say what it costs". LaraFly does the second, and this is the saying. Implement `PagingAndSortingRepository` on anything you intend to browse against a real table.
+
+### Walking the model: relations, filters, and a map
+
+A listing you can only scroll is a table dump. Three things turn it into something you explore.
+
+**Relations are discovered by CALLING the methods that declare one**, because that is the only way to learn which columns they join on — a method's name says nothing and its return type says only the kind. Which makes "what is safe to call" the load-bearing question, and the answer is the **declared return type**: only a public, non-static, no-argument method returning an Eloquent `Relation` subclass is ever called. A method announcing `: HasMany` is a relation definition by construction — the same signal Laravel's own `with()` validation and IDE tooling rely on — and an accessor cannot claim it without lying about its signature. The call executes no query; Eloquent defers until `get()`.
+
+A `belongsTo` opens the one parent record; a `hasMany` opens the child listing **filtered to this row's key**, which is what the record page needs a filter for. A relation whose other end is not a browsable resource is still shown — it tells you the shape of the model — but is not linked, and that distinction lives in the model rather than the template so a view cannot mint a URL that 404s.
+
+**Filtering is eight comparisons over the columns the resource already publishes** — `is`, `is not`, `contains`, `starts with`, `greater`, `less`, `is empty`, `is not empty` — expressed as a GET URL you can bookmark or paste into a ticket. Every comparison binds its value, including the `LIKE` ones, where the wildcards go around an *escaped* value rather than the value going into a pattern. A column the schema does not publish and an operator outside that set are **dropped** rather than passed to the driver: both arrive in a URL an operator can hand-edit, and a query reaching the driver with an arbitrary identifier in it is a column-name oracle at best. Conditions AND with each other and with the search box, so narrowing a relation's listing cannot escape it.
+
+The same eight are implemented for the in-PHP fallback path, because a repository that cannot page must be filtered by the same rules as one that can. Two implementations of one predicate drift, and the drift shows up as a filter meaning different things on different resources.
+
+**`/firefly/data-map` draws it.** Every browsable entity as a box with its columns, every foreign key as a labelled edge, boxes linking into their own records. A `hasMany` and the `belongsTo` facing it are *one* key seen from two ends, so each is drawn once — pointing from the table that holds the key to the table it references, which is also what the arrow means.
+
+### Two more pages, and the one that changes things
+
+**`/firefly/datasource`** answers what a config dump cannot. Which database am I talking to (driver, host, database, with `password` masked by the same masker the `env` endpoint uses)? Is it *up* — one connection probed per page load, because opening a socket can hang against a firewalled host and a page that opened every configured connection would take the slowest one's timeout to render, on the page you opened *because* something is wrong. What does pooling mean here — PHP has no connection pool, and rather than invent a gauge the page reports PDO's `ATTR_PERSISTENT` for what it is, and says that under php-fpm the effective pool size is your worker count. And what did `#[Transactional]` compile to, which until now existed only as an artifact under `bootstrap/cache`.
+
+**`/firefly/settings`** is the only page in the dashboard that *changes* the application rather than describing it, and it is gated accordingly: `firefly.admin.settings.enabled` (off by default, unlike everything else here), `.writable` on top of it, and a third gate that is **not a configuration key** — in production every write is refused whatever the other two say. That last one is deliberate. It is the difference between "we made it safe" and "we made it configurable to be safe", and only the first survives someone copying a `.env`.
+
+It is a *feature switch*, not a remote configuration endpoint: the list is fixed and framework-owned, so a crafted POST naming `app.key` or a database host finds nothing to write. A change goes to one JSON file under `bootstrap/cache`, filtered on the way in as well as out, and merged over configuration in the provider's `register()` — not in a boot pass, and that distinction cost a debugging session. Every settings object in this framework is built once from config and held, so applying the overrides from the dashboard's own pass wrote the file and showed the new state on the page while `/openapi.json` kept answering 200. `register()` runs before any bean resolves, which is the only point at which the merge is true.
+
!!! laravel "Laravel parity"
- Plain Laravel ships no health-check or metrics endpoint at all — most teams either hand-roll a `/health` route or reach for a third-party package, usually paired with the `ext-prometheus` extension. `firefly/actuator` and `firefly/observability` are first-party, dependency-light analogues of Spring Boot Actuator and Micrometer respectively: framework endpoints mounted on the same `Router` your app already uses, health checks that reuse Laravel's own `DB`/`Log`/config underneath, and a pure-PHP Prometheus exporter with no extension requirement. Both packages are opt-in Composer dependencies and both are secure-by-default — an app that adds `firefly/actuator` gets `health`/`info` and nothing else until it configures more.
+ Plain Laravel ships no health-check or metrics endpoint at all — most teams either hand-roll a `/health` route or reach for a third-party package, usually paired with the `ext-prometheus` extension. `firefly/actuator` and `firefly/observability` are first-party, dependency-light analogues of Spring Boot Actuator and Micrometer respectively: framework endpoints mounted on the same `Router` your app already uses, health checks that reuse Laravel's own `DB`/`Log`/config underneath, and a pure-PHP Prometheus exporter with no extension requirement. Both packages are opt-in Composer dependencies and both are secure-by-default — an app that adds `firefly/actuator` gets `health`/`info` and nothing else until it configures more. `firefly/admin` completes the set as the analogue of Spring Boot Admin, with the difference that it is not a separate monitoring application you deploy and register instances with: it is Blade views inside the application it reports on, which is why it can read the registry directly and why its access model matters as much as it does.
---
@@ -667,6 +1014,19 @@ Finally, a `Tracer` port rounds out the package — a minimal `trace(string $nam
| `PrometheusTextFormat` | Locale-safe exposition — `number_format()`, never `sprintf('%f')` |
| `MetricsFilter` | `#[Order(-100)]` outermost timing filter; tags by route **template**, never raw path — bounded cardinality |
| `ObservabilityAutoConfiguration` `#[Order(500)]` | The same precedence trick as Chapter 10's security seam: registers `cqrsMetrics()` before `CqrsAutoConfiguration` evaluates its `#[ConditionalOnMissingBean]` |
+| `firefly/admin` | A server-rendered Blade dashboard at `/firefly`; a page whose endpoint is unregistered or switched off is hidden from the menu rather than linked |
+| `AdminEndpointReader` | Invokes each `ActuatorEndpoint` **in-process** from `ActuatorRegistry`, bypassing `ExposureModel` — so the dashboard shows what the HTTP surface does not expose, and a throwing endpoint degrades one panel |
+| `BeanGraph` | Turns the beans catalogue into a drawn dependency graph over **three kinds of node** — components, `#[Bean]` products and `#[ConfigProperties]` DTOs — with `injects`/`produces` edges resolved through an interface index (marked `via`), longest-path layering, cycles reported rather than hung on, and the diagram suppressed past `firefly.admin.graph.max-nodes` (220) |
+| `#[Bean]` products as nodes | A framework's wiring lives in factory methods, not constructors; with only declaring classes as nodes a stock skeleton drew **one** edge out of 42 beans |
+| `ComponentDescriptor::$dependencies` | The graph's edges, recorded by `ComponentScanner` at **scan** time — class and interface types only, because a scalar parameter is configuration, not wiring |
+| `firefly.admin.enabled` | Defaults to `app.debug`; an explicit value wins in both directions, and turning it on with debug off obliges you to put your own auth middleware in front of the route |
+| `firefly.admin.data.enabled` | The data browser's own gate, defaulting to **`false`** — it does *not* follow `app.debug` or `firefly.admin.enabled`, because this page shows facts about the application's users rather than about the application |
+| `RelationIntrospector` | Finds relations by CALLING only the methods whose declared return type is an Eloquent `Relation`, so a record links to what it references and the entity map has edges to draw |
+| `DataFilter` | Eight comparisons over the columns the resource publishes, always bound, with an unknown column or operator dropped rather than passed to the driver |
+| `/firefly/datasource` | Connections with secrets masked, one probed per load, PDO persistence reported for what it is, and the compiled `#[Transactional]` contract |
+| `/firefly/settings` | The one page that changes the application: off by default, writable by a second key, and refused in production by a gate no key lifts |
+| `firefly.admin.data.writable` | A **second** gate, also `false` and ineffective alone: visibility and custody are different decisions, and one key would arm the delete button for whoever wanted to look at a table |
+| No `create()` | Permanent, not pending: an aggregate's invariants live in its constructor, and a form built from a column list cannot satisfy them — writing the columns anyway produces a row the domain considers impossible |
---
@@ -675,3 +1035,6 @@ Finally, a `Tracer` port rounds out the package — a minimal `trace(string $nam
1. **Add a custom `HealthIndicator`.** Write a `#[Component]` implementing `HealthIndicator` that checks something specific to your own app (a feature flag, a queue depth, a cache connection) and confirm it appears in `GET /actuator/health`'s `components` map once `show-details` is set to `always`.
2. **Configure a real liveness/readiness split.** Add `firefly.management.endpoint.health.group.liveness.include = 'ping'` and `...readiness.include = 'ping,db'` (with the DB indicator enabled) to a scratch project's config, and confirm `GET /actuator/health/liveness` and `GET /actuator/health/readiness` diverge the moment you make the database unreachable.
3. **Watch the CQRS metrics seam win the race.** Install `firefly/observability` into a scratch project already using `firefly/cqrs`, send a handful of commands, and inspect `GET /actuator/prometheus` for `cqrs_commands_seconds` samples — then temporarily comment out `ObservabilityAutoConfiguration`'s `#[Order(500)]` attribute (reverting to the class default) and confirm whether the metric still appears, to see the ordering trick actually matter rather than just reading about it.
+4. **Prove the dashboard's exposure bypass to yourself.** Install `firefly/admin` in the sample, leave `firefly.management.endpoints.web.exposure.include` at its default, and confirm that `GET /actuator/beans` returns a `404` while `/firefly/beans` renders the full bean list in the same process. Then set `firefly.management.endpoint.beans.enabled` to `false` and confirm the Beans entry vanishes from the dashboard's menu — the kill switch is honoured where exposure is not, and the difference between the two keys is the whole design.
+5. **Draw your own wiring, then break it.** Open `/firefly/graph` in the sample and find the arrow from `WalletService` to `EloquentWalletRepository` — note that the *Wired by* column says `WalletRepository`, the port, not `class`. Then introduce a deliberate cycle (have a `#[Service]` take a constructor parameter typed as another `#[Service]` that already depends on it), reload the page, and confirm the **Cycles** stat turns red and names both classes. Now boot the app fresh without opening the dashboard, and compare what PHP tells you about the same cycle.
+6. **Read the access default as a security decision.** Set `app.debug` to `false` in a scratch project with `firefly/admin` installed and confirm `/firefly` is genuinely unrouted rather than merely unlinked (`php artisan route:list` should not list it). Then set `firefly.admin.enabled` to `true` without adding any `HttpSecurity` rule, and look at what an unauthenticated `GET /firefly/env` now discloses — that is precisely the gap this chapter told you to close with your own auth middleware.
diff --git a/composer.json b/composer.json
index d4225c0..88b5159 100644
--- a/composer.json
+++ b/composer.json
@@ -1,6 +1,6 @@
{
"name": "firefly/monorepo",
- "description": "LaraFly — the Firefly Framework for PHP. Development monorepo.",
+ "description": "LaraFly \u2014 the Firefly Framework for PHP. Development monorepo.",
"type": "project",
"license": "Apache-2.0",
"require": {
@@ -17,15 +17,18 @@
"firefly/kernel": "*@dev",
"firefly/messaging": "*@dev",
"firefly/observability": "*@dev",
+ "firefly/openapi": "*@dev",
"firefly/resilience": "*@dev",
"firefly/scheduling": "*@dev",
"firefly/scheduling-postgres": "*@dev",
"firefly/security": "*@dev",
"firefly/validation": "*@dev",
- "firefly/web": "*@dev"
+ "firefly/web": "*@dev",
+ "swagger-api/swagger-ui": "^5.17"
},
"require-dev": {
"deptrac/deptrac": "^4.6",
+ "firefly/admin": "*@dev",
"firefly/cli": "*@dev",
"firefly/eda-kafka": "*@dev",
"firefly/eda-postgres": "*@dev",
@@ -37,7 +40,7 @@
"kwn/php-rdkafka-stubs": "^2.2",
"larastan/larastan": "^3.9",
"laravel/octane": "^2.0",
- "laravel/pint": "^1.20",
+ "laravel/pint": "^1.30",
"orchestra/testbench": "^11.1",
"pestphp/pest": "^3.7",
"phpstan/phpstan": "^2.1",
@@ -46,37 +49,51 @@
},
"autoload-dev": {
"psr-4": {
- "Firefly\\Tests\\": "tests/",
"Firefly\\Actuator\\Tests\\": "packages/actuator/tests/",
+ "Firefly\\Admin\\Tests\\": "packages/admin/tests/",
"Firefly\\AutoConfigure\\Tests\\": "packages/autoconfigure/tests/",
+ "Firefly\\Cli\\Tests\\": "packages/cli/tests/",
"Firefly\\Config\\Tests\\": "packages/config/tests/",
"Firefly\\Container\\Tests\\": "packages/container/tests/",
"Firefly\\Context\\Tests\\": "packages/context/tests/",
- "Firefly\\Cli\\Tests\\": "packages/cli/tests/",
"Firefly\\Cqrs\\Tests\\": "packages/cqrs/tests/",
- "Firefly\\Firefly\\Tests\\": "packages/firefly/tests/",
"Firefly\\Data\\Tests\\": "packages/data/tests/",
"Firefly\\Domain\\Tests\\": "packages/domain/tests/",
- "Firefly\\Eda\\Tests\\": "packages/eda/tests/",
"Firefly\\Eda\\Kafka\\Tests\\": "packages/eda-kafka/tests/",
"Firefly\\Eda\\Postgres\\Tests\\": "packages/eda-postgres/tests/",
"Firefly\\Eda\\Rabbitmq\\Tests\\": "packages/eda-rabbitmq/tests/",
+ "Firefly\\Eda\\Tests\\": "packages/eda/tests/",
+ "Firefly\\Firefly\\Tests\\": "packages/firefly/tests/",
"Firefly\\Installer\\Tests\\": "packages/installer/tests/",
"Firefly\\Messaging\\Tests\\": "packages/messaging/tests/",
"Firefly\\Observability\\Tests\\": "packages/observability/tests/",
+ "Firefly\\OpenApi\\Tests\\": "packages/openapi/tests/",
"Firefly\\Resilience\\Tests\\": "packages/resilience/tests/",
"Firefly\\Scheduling\\Postgres\\Tests\\": "packages/scheduling-postgres/tests/",
"Firefly\\Scheduling\\Tests\\": "packages/scheduling/tests/",
"Firefly\\Security\\Tests\\": "packages/security/tests/",
"Firefly\\Testing\\Tests\\": "packages/testing/tests/",
+ "Firefly\\Tests\\": "tests/",
"Firefly\\Validation\\Tests\\": "packages/validation/tests/",
"Firefly\\Web\\Tests\\": "packages/web/tests/",
"Lumen\\Tests\\": "samples/lumen/tests/"
}
},
"repositories": [
- { "type": "path", "url": "packages/*", "options": { "symlink": true } },
- { "type": "path", "url": "samples/*", "options": { "symlink": true } }
+ {
+ "type": "path",
+ "url": "packages/*",
+ "options": {
+ "symlink": true
+ }
+ },
+ {
+ "type": "path",
+ "url": "samples/*",
+ "options": {
+ "symlink": true
+ }
+ }
],
"minimum-stability": "stable",
"prefer-stable": true,
@@ -93,9 +110,14 @@
"pint": "pint",
"pint-test": "pint --test",
"stan": "phpstan analyse --no-progress --memory-limit=1G",
- "test": "pest",
+ "test": "@php -d memory_limit=512M vendor/bin/pest",
"deptrac": "deptrac analyse --no-progress --config-file=deptrac.yaml",
"mono-validate": "monorepo-builder validate",
- "check": [ "@pint-test", "@stan", "@test", "@deptrac" ]
+ "check": [
+ "@pint-test",
+ "@stan",
+ "@test",
+ "@deptrac"
+ ]
}
}
diff --git a/deptrac.yaml b/deptrac.yaml
index 78ae9f8..a9dbd73 100644
--- a/deptrac.yaml
+++ b/deptrac.yaml
@@ -86,6 +86,14 @@ deptrac:
collectors:
- type: directory
value: packages/observability/src/.*
+ - name: Admin
+ collectors:
+ - type: directory
+ value: packages/admin/src/.*
+ - name: OpenApi
+ collectors:
+ - type: directory
+ value: packages/openapi/src/.*
- name: Testing
collectors:
- type: directory
@@ -328,6 +336,49 @@ deptrac:
- Cqrs
- Resilience
+ # Admin is the browser front-end for Actuator: it renders the SAME ActuatorEndpoint beans the JSON surface
+ # serves, resolved in-process from Actuator's registry rather than fetched over HTTP (=> Actuator), mounts
+ # its pages on the illuminate Router through a Context BootPass at a configurable base path, and reuses
+ # Web's ProblemDetailsRenderer for its own failures (=> Web). It reads Config, carries Container/Context
+ # attributes and extends AutoConfigure's base. NOTHING depends on it — it is a leaf, and an optional one.
+ #
+ # The => Data edge is the Django-style database browser (src/Data): it discovers browsable resources by
+ # looking for beans whose scan-time interface list contains Data's CrudRepository, and reads them through
+ # the ports Data already publishes — findPaged(Pageable)/Page/Sort for a page, findBySpecificationPaged
+ # for a searched page, findById/existsById/deleteById/save for a record. It consumes those ports and adds
+ # nothing to them; the direction is Admin -> Data and NEVER the reverse, exactly as with Actuator and Web.
+ # No Domain edge: the browser reflects over whatever entity a repository declares and knows nothing about
+ # Entity/AggregateRoot.
+ Admin:
+ - Kernel
+ - Container
+ - Config
+ - Context
+ - AutoConfigure
+ - Web
+ - Actuator
+ - Data
+
+ # OpenApi is a top-of-stack capability (like Web/Cqrs/Security/Actuator): it generates an OpenAPI 3.1
+ # document from manifests that already exist, so it is nearly all READS. It reads Web's RouteManifest/
+ # RouteDescriptor (paths, verbs, statuses, binding plans) and Validation's ConstraintManifest + Rule
+ # objects (body schemas and their `required` lists), and mirrors Kernel's ErrorResponse/ErrorCategory/
+ # ErrorSeverity into the shared RFC-9457 problem component. It reads Config, carries Container
+ # stereotypes + Context boot/condition attributes, extends AutoConfigure's base, and mounts its two
+ # routes on the illuminate Router from a BootPass (the ActuatorRouteRegistrar precedent) because an
+ # attribute route cannot carry a configurable path. NO edge to Actuator (the two surfaces are
+ # independent; the ordering between their registrars is a tie-break, not a dependency) and NO edge to
+ # Cli (its firefly:openapi command is registered by its OWN provider, so the command ships wherever the
+ # package does — firefly/cli is require-dev in a real app). NOTHING depends on OpenApi.
+ OpenApi:
+ - Kernel
+ - Container
+ - Config
+ - Context
+ - AutoConfigure
+ - Validation
+ - Web
+
# Testing is the top-of-stack test-support kit: its recording doubles implement every capability
# package's frozen ports, so it depends on ALL layers. It is depended on by NONE — consumers use it
# only from their tests/, which Deptrac's src-only collectors never see (no cycle; dev-scoped dep).
diff --git a/docs/README.md b/docs/README.md
index ff69620..8ad037e 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -44,8 +44,9 @@ Every module guide lives under [`modules/`](modules/), grouped below the same wa
| Guide | Description |
|-------|-------------|
-| [Web Layer](modules/web.md) | `firefly/web` — `#[RestController]` routing, parameter binding, RFC-7807 error rendering |
+| [Web Layer](modules/web.md) | `firefly/web` — `#[RestController]`/`#[Controller]` routing, parameter binding, JSON + HTML negotiation, RFC-7807 error rendering |
| [Web Filters](modules/web-filters.md) | An ordered `WebFilter` chain bridged onto Laravel's own middleware pipeline |
+| [OpenAPI](modules/openapi.md) | `firefly/openapi` — an OpenAPI 3.1 document generated from the compiled manifests, `firefly:openapi`, and the official Swagger UI served from your own origin |
### Resilience & Scheduling
@@ -89,6 +90,9 @@ Every module guide lives under [`modules/`](modules/), grouped below the same wa
|-------|-------------|
| [Actuator](modules/actuator.md) | `firefly/actuator` — health/info/beans endpoints, the Spring-Boot-Actuator analogue |
| [Observability](modules/observability.md) | `firefly/observability` — the `MeterRegistry`, Prometheus/Micrometer-JSON exposition, CQRS metrics |
+| [Admin Dashboard](modules/admin.md) | `firefly/admin` — the browser dashboard over the actuator; reads its endpoints in-process, so its own URL is the security boundary |
+| [Bean Graph](modules/bean-graph.md) | The dashboard's drawn dependency graph — components, `#[Bean]` products and `#[ConfigProperties]` DTOs as nodes, interface-resolved edges, longest-path layering, cycle reporting |
+| [Data Browser](modules/data-browser.md) | A Django-style database browser over `CrudRepository` beans — **off by default**, writes behind a second gate, with filtering, paging, relations you can walk, and an entity map |
### Testing
@@ -113,7 +117,7 @@ Every module guide lives under [`modules/`](modules/), grouped below the same wa
| [Laravel Comparison](laravel-comparison.md) | Side-by-side concept mapping for developers coming from plain Laravel |
| [Versioning](versioning.md) | CalVer (`YY.MM.Patch`), no `version` field, how Packagist derives releases from tags |
| [Contributing](contributing.md) | Monorepo layout, local setup, conventions, how to add a package |
-| [Publishing](publishing.md) | The release/split runbook — one CalVer tag, 26 shippable units |
+| [Publishing](publishing.md) | The release/split runbook — one CalVer tag, 28 shippable units |
---
@@ -131,7 +135,11 @@ Every module guide lives under [`modules/`](modules/), grouped below the same wa
- **Writing commands/queries?** See [CQRS](modules/cqrs.md).
- **Securing an app?** See [Security](modules/security.md).
- **Shipping to production?** See [Actuator](modules/actuator.md), [Observability](modules/observability.md),
- and [Resilience](modules/resilience.md).
+ and [Resilience](modules/resilience.md) — then [Admin Dashboard](modules/admin.md) for the browser view over
+ all three, and read its [access model](modules/admin.md#access-the-whole-security-boundary) before enabling it
+ outside `app.debug`.
+- **Publishing an API?** See [OpenAPI](modules/openapi.md) — the spec is generated from the same manifests the
+ dispatcher and validator use, so it cannot drift.
- **Writing tests?** See [Testing](modules/testing.md) and [Integration Testing](modules/integration-testing.md).
- **Releasing a version?** See [Versioning](versioning.md) and [Publishing](publishing.md), and check the
[`CHANGELOG.md`](../CHANGELOG.md) at the repo root.
@@ -139,7 +147,7 @@ Every module guide lives under [`modules/`](modules/), grouped below the same wa
---
-*The guided, book-style [*LaraFly by Example*](../book/README.md) book — 13 chapters plus appendices,
+*The guided, book-style [*LaraFly by Example*](../book/README.md) book — 14 chapters plus appendices,
bilingual (English + Spanish), rendered to PDF + EPUB — is available now, alongside the step-by-step
[Tutorial](tutorial.md).*
diff --git a/docs/cli.md b/docs/cli.md
index 8395ede..ef66372 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -23,6 +23,7 @@ bootstrap/cache/firefly/
├── context.php # application-context manifest
├── config-properties.php # #[ConfigProperties] DTOs
├── routes.php # compiled route table
+├── exception-handlers.php # #[ControllerAdvice]/#[ExceptionHandler] manifest
├── constraints.php # validation constraint manifest
├── handlers.php # #[CommandHandler]/#[QueryHandler] manifest
├── event-listeners.php # #[EventListener] manifest
@@ -34,10 +35,10 @@ bootstrap/cache/firefly/
└── proxies/ # one generated proxy class file per #[Transactional] target
```
-A `FireflyCacheServiceProvider` (auto-discovered) binds these compiled manifests over each capability's
-`bound()`-guarded empty default, registers the `#[ConfigProperties]` bindings, and installs a `spl_autoload_register`
-classmap loader for the proxy classes — all before any bean resolution runs, giving a fully cached, reflection-free
-boot. Two config keys point the boot path at the cache:
+A `FireflyCacheServiceProvider` (auto-discovered with `firefly/cli`) `instance()`s these compiled manifests over
+whatever the capability packages resolved, registers the `#[ConfigProperties]` bindings, and installs a
+`spl_autoload_register` classmap loader for the proxy classes — all before any bean resolution runs, giving a fully
+cached, reflection-free boot. Three config keys point the boot path at the cache:
```php
'firefly' => [
@@ -49,9 +50,33 @@ boot. Two config keys point the boot path at the cache:
],
```
-When `component_manifest`/`context_manifest` point at files that exist, `FireflyAutoConfigureServiceProvider` loads
-them via `::load()` instead of scanning `firefly.scan.paths` in-process. Without a cache, the app still boots — via
-the in-process scanner fallback — just without the zero-reflection guarantee.
+### Cached and uncached boots
+
+Every manifest above is resolved by the same three-step convention, and `Firefly\Context\Scan\AppScan` is the
+seam each capability package uses to do it:
+
+1. the compiled artifact exists under `firefly.cache.path` → load it, zero reflection (production);
+2. otherwise `firefly.scan.paths` is non-empty → scan those PSR-4 roots **in-process**, on every boot (development);
+3. otherwise → an empty manifest, and boot still succeeds.
+
+`FireflyAutoConfigureServiceProvider` has always done this for the component and context manifests (via
+`component_manifest`/`context_manifest`). It is now also what routes, `#[ControllerAdvice]` handlers, CQRS handlers,
+event and message listeners, scheduled tasks, validation constraints, method-security rules, `#[ConfigProperties]`
+DTOs and the `#[Transactional]` manifest do — so an application that has never run `firefly:cache` behaves the same
+as one that has, and pays a full reflection scan per boot for the privilege.
+
+That is a change, not a restatement: **before it, step 2 did not exist.** Every capability bound an *empty*
+manifest and only `firefly/cli`'s `FireflyCacheServiceProvider` ever replaced it, which made a `require-dev` tool
+the sole owner of the loading half of the contract. An app that skipped the compile step — or that installed the
+`firefly/firefly` metapackage, which did not require the CLI — booted with no routes (404 on everything it owned)
+and, worse, with an empty method-security manifest: both enforcement sites read "no rule for this method" as ALLOW,
+so `#[PreAuthorize]`, `#[Secured]` and `#[RolesAllowed]` all failed **open**. `firefly/cli` is
+now part of the `firefly/firefly` metapackage, and `firefly.security.method.strict` (default `false`) makes the
+strict reading available to anyone who wants a build that ships without a compiled manifest to refuse to boot
+rather than run unprotected.
+
+Compiling is still worth it — reflection-free boot is the point of `firefly:cache` — but it is now an optimisation
+rather than a correctness requirement.
## `firefly:clear`
@@ -105,12 +130,31 @@ Pyfly's `generate` command family, one Artisan generator per stereotype:
| `make:firefly-controller` | A `#[RestController]` with a sample `#[GetMapping]` action, under `app/Http`. |
| `make:firefly-service` | A `#[Service]` bean. |
| `make:firefly-component` | A `#[Component]` bean. |
-| `make:firefly-handler` | A `#[CommandHandler]` by default, or a `#[QueryHandler]` with `--query`. |
-| `make:firefly-listener` | An `#[EventListener]` method by default, or a `#[MessageListener]` with `--message`. |
+| `make:firefly-handler` | **Two files**: a `#[CommandHandler]` *and* the command class its `handle()` takes (`#[QueryHandler]` + query with `--query`). |
+| `make:firefly-listener` | A `#[Component]` class with an `#[EventListener]` method, or a `#[MessageListener]` one with `--message`. |
| `make:firefly-entity` | A DDD entity extending `Firefly\Domain\Entity` (there is no `#[Entity]` attribute). |
-| `make:firefly-repository` | A repository interface extending `Firefly\Data\Repository\CrudRepository`. |
+| `make:firefly-repository` | A concrete `#[Repository]` class extending `Firefly\Data\Repository\EloquentRepository`, with a `$model` to repoint. |
| `make:firefly-config-properties` | A `#[ConfigProperties]`-bound configuration DTO. |
+Three of those outputs are shaped by what the scanners actually accept, and it is worth knowing why:
+
+- **The handler generator emits its message class too.** `HandlerScanner` infers a bare `#[CommandHandler]`'s
+ message type from `handle()`'s sole parameter, and a builtin type (the old stub's `object $command`) cannot be
+ resolved — it threw `CqrsConfigurationException` out of `firefly:cache`, aborting the *whole* compile. So the
+ generated `handle()` takes a concrete class, and that class is written alongside it: `RegisterWidgetHandler` +
+ `RegisterWidget`, `CountWidgetsHandler` + `CountWidgets`. A message file that already exists is left alone and
+ reported, never overwritten. Nested names stay together (`make:firefly-handler Widget/RegisterWidgetHandler`
+ puts both in the same sub-namespace).
+- **The listener generator puts `#[Component]` on the class.** `#[EventListener]`/`#[MessageListener]` mark a
+ method of a *bean*; without a stereotype `ComponentScanner::describe()` returns null, the class never reaches
+ the component manifest, and the wiring pass's `$container->make()` falls through to Illuminate's reflective
+ auto-build — a plain object outside Firefly's lifecycle, with no `#[Value]` injection, no post-processing and a
+ new instance per delivery.
+- **The repository generator emits a class, not an interface.** Nothing synthesises an implementation for a
+ repository interface (there is no Spring-Data dynamic proxy here), so the old `interface X extends CrudRepository`
+ was unresolvable by construction. The generated class is deliberately not `final`, because `firefly:cache` emits
+ a `#[Transactional]` proxy that `extends` it.
+
```
php artisan make:firefly-controller GreetingController
php artisan make:firefly-service GreetingService
@@ -139,3 +183,14 @@ php artisan firefly:db {action=migrate}
Delegates to Laravel's own database commands: `migrate` (default), `db:seed` (`firefly:db seed`), or
`migrate:fresh` (`firefly:db fresh`). Neither command reimplements any Laravel behavior — both are thin
`$this->call(...)` passthroughs.
+
+## Commands contributed by other packages
+
+`firefly/cli` is not the only package that registers Artisan commands; a capability package ships its own where the
+command is part of that capability rather than of the console.
+
+| Command | Package | What it does |
+|---|---|---|
+| `firefly:openapi` | `firefly/openapi` | Writes the generated OpenAPI 3.1 document to `--output=` (parent directories are created, and a summary line is printed) or **raw** to stdout. Stdout is written with Symfony's `OUTPUT_RAW` so the bytes are exactly the document's — `php artisan firefly:openapi \| ` is the intended use — which is also why the confirmation line prints only in `--output` mode. See [OpenAPI](modules/openapi.md#php-artisan-fireflyopenapi). |
+| `firefly:eda:consume` | `firefly/eda` | Binds the configured broker destinations and runs the consumer loop. See [EDA](modules/eda.md). |
+| `firefly:outbox:relay` | `firefly/eda-postgres` | Forwards committed outbox rows to a second broker. See [EDA Brokers](modules/eda-brokers.md). |
diff --git a/docs/getting-started.md b/docs/getting-started.md
index a8b3e44..a7cf947 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -3,8 +3,8 @@
## Quickstart: `firefly/skeleton`
The fastest way to a booting, cached LaraFly app is the `firefly/skeleton` create-project template — a Laravel 13
-application pre-wired with the Firefly family, a sample `#[RestController]`/`#[Service]` pair, and
-`firefly:cache` already wired into `post-create-project-cmd`:
+application pre-wired with the Firefly family, a `#[Controller]` welcome page, a sample
+`#[RestController]`/`#[Service]` pair, and `firefly:cache` already wired into `post-create-project-cmd`:
```bash
composer create-project firefly/skeleton my-app
@@ -13,26 +13,29 @@ php artisan firefly:cache
php artisan firefly:serve
```
-`composer create-project` alone already ran `firefly:cache` for you (via `post-create-project-cmd`), so the app
-boots reflection-free from the first request; re-run `firefly:cache` yourself whenever you add or change
-`#[Component]`/`#[RestController]`/`#[CommandHandler]`/etc. classes, and `firefly:clear` to fall back to the
-in-process scanner. `firefly:serve` is a thin passthrough to `artisan serve` (or `octane:start` when
-`laravel/octane` is installed) — see [CLI](cli.md) for the full command reference.
+`composer create-project` alone already ran `migrate` and `firefly:cache` for you (via
+`post-create-project-cmd`), so the app boots reflection-free and its sample `POST /orders` persists from the
+first request; re-run `firefly:cache` whenever you add or change
+`#[Component]`/`#[RestController]`/`#[CommandHandler]`/etc. classes. `firefly:clear` drops back to the
+in-process scan, which costs a reflection pass per boot but is functionally identical — every manifest
+resolves to the compiled artifact if present, otherwise a scan of `firefly.scan.paths`, otherwise empty.
+
+`firefly:serve` is a thin passthrough to `artisan serve` (or `octane:start` when `laravel/octane` is
+installed) — see [CLI](cli.md) for the full command reference.
## Adding LaraFly to an existing Laravel app
Pull in the whole runtime family with one line — `firefly/firefly` is a Composer metapackage (the Maven BOM
-analogue) that requires every runtime package (`firefly/kernel` through `firefly/observability`):
+analogue) that requires every runtime package, `firefly/cli` included, so `firefly:cache` and the
+`make:firefly-*` generators are available straight away:
```bash
composer require firefly/firefly
```
-Add `firefly/cli` for the developer-experience console (`firefly:cache`, `make:firefly-*`, and friends):
-
-```bash
-composer require --dev firefly/cli
-```
+The browser dashboard (`firefly/admin`) and the API-documentation package (`firefly/openapi`) come with it. The
+broker adapters (`firefly/eda-rabbitmq`, `firefly/eda-postgres`, `firefly/eda-kafka`) and the test kit
+(`firefly/testing`) stay separate — each binds you to an infrastructure choice or belongs in `require-dev`.
Then point LaraFly at your app's classes and compile it:
@@ -56,10 +59,11 @@ php artisan firefly:serve
`firefly:about`/`:routes`/`:health`/`:metrics`, the `make:firefly-*` generator family, and thin
`firefly:serve`/`:db` passthroughs. See [CLI](cli.md).
- **`firefly/firefly`** — a `type: metapackage` runtime aggregator; `composer require firefly/firefly` pulls the
- whole runtime family in one line.
+ whole runtime family in one line, `firefly/cli` among them. (It is in the metapackage deliberately: while it
+ was `require-dev`-only, an application that never ran `firefly:cache` booted with empty manifests.)
- **`firefly/skeleton`** — a `type: project` Laravel 13 create-project template, pre-wired with the Firefly family
- and a sample `#[RestController]`/`#[Service]`, that yields a booting, cached app straight out of
- `composer create-project`.
+ and a sample `#[Controller]`/`#[RestController]`/`#[Service]` slice, that yields a booting, cached app
+ straight out of `composer create-project`.
## Where to next
diff --git a/docs/index.md b/docs/index.md
index 26d0d39..418bf12 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -27,7 +27,13 @@ request after that runs against plain PHP arrays — no runtime reflection on th
- **Secure by default** — a Spring-Security-6-shaped principal model, deny-by-default `HttpSecurity` URL DSL,
and method security (`#[PreAuthorize]`) enforced with no proxy magic.
- **Production-ready out of the box** — an Actuator surface (health/info/beans) and a Prometheus/Micrometer-style
- metrics core, both secured by the same config as everything else.
+ metrics core, both secured by the same config as everything else, plus a server-rendered
+ [admin dashboard](modules/admin.md) over them with a drawn [bean graph](modules/bean-graph.md), and an
+ opt-in, off-by-default [data browser](modules/data-browser.md) over your own repositories, with filtering,
+ full CRUD, relations you can walk and a drawn [entity map](modules/admin.md#the-entity-map).
+- **An API document that cannot drift** — [`firefly/openapi`](modules/openapi.md) generates OpenAPI 3.1 from the
+ same compiled manifests the dispatcher and the validator read, and serves the official Swagger UI from your own
+ origin — no annotation dialect, no npm, no CDN.
- **A first-party test kit** — a boot harness, recording doubles for every port, and web/data test-slice
builders, dogfooded across the framework's own test suite.
@@ -58,13 +64,13 @@ Module guides are grouped by concern under [`modules/`](modules/error-handling.m
| Group | Guides |
|---|---|
| **Foundation** | [Error Handling](modules/error-handling.md) · [Dependency Injection](modules/dependency-injection.md) · [Configuration](modules/configuration.md) · [Application Context](modules/context.md) · [Auto-Configuration](modules/starters.md) · [Validation](modules/validation.md) |
-| **Web & API** | [Web Layer](modules/web.md) · [Web Filters](modules/web-filters.md) |
+| **Web & API** | [Web Layer](modules/web.md) · [Web Filters](modules/web-filters.md) · [OpenAPI](modules/openapi.md) |
| **Resilience & Scheduling** | [Resilience](modules/resilience.md) · [Scheduling](modules/scheduling.md) |
| **Data & Domain** | [Domain (DDD)](modules/domain.md) · [Data & Repositories](modules/data.md) · [Relational Data](modules/data-relational.md) · [Transactions](modules/transactional.md) |
| **Eventing & Messaging** | [EDA](modules/eda.md) · [EDA Brokers](modules/eda-brokers.md) · [Messaging](modules/messaging.md) |
| **CQRS** | [Command/Query](modules/cqrs.md) |
| **Security** | [Security](modules/security.md) |
-| **Operations** | [Actuator](modules/actuator.md) · [Observability](modules/observability.md) |
+| **Operations** | [Actuator](modules/actuator.md) · [Observability](modules/observability.md) · [Admin Dashboard](modules/admin.md) · [Bean Graph](modules/bean-graph.md) · [Data Browser](modules/data-browser.md) |
| **Testing** | [Testing](modules/testing.md) · [Integration Testing](modules/integration-testing.md) |
| **Tooling** | [Installer](modules/installer.md) |
@@ -76,7 +82,7 @@ Want to see it all running together? The
[Lumen sample](https://github.com/fireflyframework/fireflyframework-php/tree/main/samples/lumen) is a
runnable digital-wallet & ledger vertical slice exercising `#[Transactional]`, CQRS, domain events over EDA,
method security, and a REST layer with RFC-7807 problem-details. The guided, book-style *LaraFly by Example*
-book — 13 chapters plus appendices, bilingual (English + Spanish), building this exact sample — is available
+book — 14 chapters plus appendices, bilingual (English + Spanish), building this exact sample — is available
in [`book/`](https://github.com/fireflyframework/fireflyframework-php/tree/main/book).
## Quick Links
diff --git a/docs/installation.md b/docs/installation.md
index b24d3cc..d47c2ed 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -49,13 +49,16 @@ A runnable Laravel 13 application, already on the cached, zero-reflection boot p
- **sqlite** for the database and the **array**/**sync** drivers for cache/queue — zero external services
required to boot.
-- A sample `#[RestController]` + `#[Service]` pair wired end-to-end, so `php artisan firefly:serve` gives you
- a working HTTP endpoint immediately.
+- A `#[Controller]` welcome page (the HTML stereotype) plus a sample `#[RestController]` + `#[Service]` pair
+ wired end-to-end, so `php artisan firefly:serve` gives you a working page and a working JSON endpoint
+ immediately.
- A sample `#[ConfigProperties]` DTO showing typed config binding.
-- `bootstrap/cache/firefly/` already populated — every scanner→compiler pair (DI, routes, config, CQRS
- handlers, event listeners, scheduled tasks, security methods, `#[Transactional]` proxies) has already run,
- so the first request boots with no reflection at all. Re-run `php artisan firefly:cache` whenever you add or
- change an annotated class; `php artisan firefly:clear` falls back to the in-process scanner.
+- `config/firefly.php` as a full, commented reference of every `firefly.*` key the framework reads.
+- `bootstrap/cache/firefly/` already populated — every scanner→compiler pair (DI, routes, exception handlers,
+ validation constraints, config properties, CQRS handlers, event/message listeners, scheduled tasks, security
+ methods, `#[Transactional]` proxies) has already run, so the first request boots with no reflection at all.
+ Re-run `php artisan firefly:cache` whenever you add or change an annotated class; `php artisan
+ firefly:clear` drops back to the in-process scan, which is slower per boot but functionally identical.
## Next steps
diff --git a/docs/laravel-comparison.md b/docs/laravel-comparison.md
index 0d82a40..e50ab75 100644
--- a/docs/laravel-comparison.md
+++ b/docs/laravel-comparison.md
@@ -14,7 +14,7 @@ draws for Python, mapped onto Laravel instead.
| Entry point | Service providers registered in `bootstrap/providers.php`, wired by hand | The same providers, plus auto-discovered `AutoConfiguration` classes assembled by a kernel-decided `BootPass` pipeline |
| Dependency injection | `app()->bind()`/`app()->singleton()` in a provider's `register()` | `#[Component]`/`#[Service]`/`#[Repository]`/`#[Configuration]` stereotypes on the class itself; compiled component scan resolves constructor dependencies |
| Configuration | `config('mail.host')` (array access, untyped) | `#[ConfigProperties]` DTOs bound from a config subtree — typed, fail-fast on a missing/mismatched key |
-| HTTP routing | `routes/web.php`/`routes/api.php` route files | `#[RestController]` + verb attributes (`#[GetMapping]`, …), compiled to a `RouteManifest`, still dispatched through native Laravel routes |
+| HTTP routing | `routes/web.php`/`routes/api.php` route files | `#[RestController]` (JSON) / `#[Controller]` (HTML) + verb attributes (`#[GetMapping]`, …), compiled to a `RouteManifest`, still dispatched through native Laravel routes |
| Validation | `FormRequest::rules()` (array rules) | `#[Valid]` parameter interception over a Bean-Validation-style constraint model, still backed by Laravel's validator |
| Transactions | `DB::transaction(fn () => …)` (closure-scoped) | `#[Transactional]` on a class/method — declarative propagation/isolation/rollback rules, manual `beginTransaction`/`commit`/`rollBack` under the hood |
| Events | `Event::listen()` / `#[AsEventListener]`-style Laravel listeners, in-process only | Two distinct surfaces: the in-process bus (`#[AsEventListener]`) **and** a broker-backed EDA bus (`#[EventListener]`) — see below |
@@ -55,8 +55,10 @@ files remain the source of truth; LaraFly reads them, it doesn't replace them.
Laravel routes live in `routes/*.php`, separate from the controller class. `firefly/web`'s
`#[RestController]` + `#[GetMapping]`/`#[PostMapping]`/etc. attributes put the route on the controller
-method itself; a `RouteScanner` compiles them into a `RouteManifest` at cache time, and that manifest is what
-actually registers native Laravel routes at boot — there is no custom dispatch mechanism underneath. See
+method itself; a `RouteScanner` compiles them into a `RouteManifest` at cache time (or scans in-process when
+there is no cache), and that manifest is what actually registers native Laravel routes at boot — there is no
+custom dispatch mechanism underneath. `#[Controller]` is the HTML sibling: same routing, but a returned
+`View`/`ModelAndView`/`Htmlable` renders as `text/html` instead of negotiating to JSON. See
[Web Layer](modules/web.md).
## Validation: `FormRequest` vs. `#[Valid]`
diff --git a/docs/modules/actuator.md b/docs/modules/actuator.md
index 7ae895e..cec9a23 100644
--- a/docs/modules/actuator.md
+++ b/docs/modules/actuator.md
@@ -9,10 +9,19 @@ always-on, and secured entirely by M11 config with zero code edge to `firefly/se
- `/actuator` — HAL index of exposed endpoints
- `/actuator/health` (+ `/actuator/health/{group}`, liveness/readiness) — aggregated health, 503 on DOWN
-- `/actuator/info` — merged `InfoContributor` fragments (`app`, `build`)
+- `/actuator/info` — merged `InfoContributor` fragments (`runtime`, `app`, `build`)
- `/actuator/env` — the `firefly.*` config tree, sensitive values masked
+- `/actuator/configprops` — every `#[ConfigProperties]` DTO, with the values it actually resolved off the bound instance, masked
+- `/actuator/caches` (+ `/actuator/caches/{name}`) — the configured `cache.stores` (name/driver/default only); read-only, no eviction
- `/actuator/beans`, `/actuator/conditions`, `/actuator/mappings`, `/actuator/loggers` (GET/POST), `/actuator/scheduledtasks`
-- `/actuator/metrics`, `/actuator/prometheus` — supplied by `firefly/observability` when installed
+- `/actuator/metrics`, `/actuator/prometheus`, `/actuator/httpexchanges`, `/actuator/process` — supplied by
+ `firefly/observability` when installed
+
+!!! tip "A browser view over all of this"
+ `firefly/admin` renders these same endpoints as a server-side dashboard, reading them **in-process** rather
+ than over HTTP — so it shows pages the exposure model below deliberately keeps unpublished. That inversion is
+ the whole of its security model: see [Admin Dashboard](admin.md), and read
+ [its access model](admin.md#access-the-whole-security-boundary) before enabling it outside `app.debug`.
## Health
@@ -23,7 +32,8 @@ indicators. A throwing indicator degrades to DOWN — never a 500.
## Exposure & security (recommended)
Default `firefly.management.endpoints.web.exposure.include = "health,info"`; sensitive endpoints return **404** until
-explicitly exposed. Lock them down with `firefly.security.http.rules` (no second management port — doesn't fit PHP-FPM):
+explicitly exposed. An endpoint body is always a JSON **object**: `/actuator/info` with no `InfoContributor`
+registered answers `{}`, not `[]`, so a typed client deserialising into a map does not break on the default. Lock them down with `firefly.security.http.rules` (no second management port — doesn't fit PHP-FPM):
```php
'firefly' => [
@@ -52,20 +62,23 @@ end-to-end: with the lockdown rules above and `env` exposed, an anonymous `GET /
(`AuthenticationException`, no matching rule's expression is satisfied) while `GET /actuator/health` stays **200**
(`permitAll`).
-`/actuator/env` additionally masks any key matching `password|secret|token|key|credential|passwd` (case-insensitive,
-recursive) with `******`, independent of whether the URL lockdown above is configured — defense in depth for an
+`/actuator/env` and `/actuator/configprops` additionally mask any key matching `password|secret|token|key|credential|passwd`
+(case-insensitive, recursive — the shared `SensitiveValueMasker`) with `******`. The key is tested **before** the value's
+type, so a sensitive key holding an array (a JWT keyring, a credentials pair) is replaced wholesale rather than recursed
+into, independent of whether the URL lockdown above is configured — defense in depth for an
endpoint that is reachable at all only once explicitly exposed.
## Configuration (`firefly.management.*`, kebab-case)
- `firefly.management.enabled` (default `true`) — master gate
-- `firefly.management.endpoints.web.exposure.include` / `.exclude` (CSV or `*`, exclude wins)
+- `firefly.management.endpoints.web.exposure.include` / `.exclude` (CSV or `*`; `*` is a wildcard in **both** lists and exclude wins, so `.exclude = "*"` is the kill switch)
- `firefly.management.endpoints.web.base-path` (default `/actuator`)
- `firefly.management.endpoint.{id}.enabled` (per-endpoint)
-- `firefly.management.endpoint.health.show-details` (`never`|`when-authorized`|`always`)
+- `firefly.management.endpoint.health.show-details` (default `never`; only the literal `always` shows component details — see Known-latent for `when-authorized`)
- `firefly.management.endpoint.health.group.{name}.include`
- `firefly.management.endpoint.health.db.enabled` (default `false`) — opt-in `Db` health indicator
- `firefly.management.info.app.*`, `firefly.management.info.build.path`
+- `firefly.management.info.runtime.enabled` (default `true`, `#[ConditionalOnProperty(matchIfMissing: true)]`) — the `runtime` fragment of `/actuator/info` (PHP version/SAPI/OPcache, Laravel version, LaraFly version, current+peak memory). Setting it `false` removes the contributor bean entirely.
## Laravel comparison
@@ -75,5 +88,5 @@ URL generation, the HTTP-kernel middleware pipeline) — not app controllers. He
## Known-latent
- `when-authorized` show-details degrades to `never` (no Security code edge; gate details via the lockdown).
-- `/httpexchanges`, `/caches`, `/configprops`, `/refresh`, `/threaddump`, `/shutdown` are deferred to later SP cycles.
+- `/refresh`, `/threaddump`, `/shutdown` are deferred to later SP cycles. `/caches` is read-only: Spring's `DELETE` eviction is deliberately not implemented, because `firefly/actuator` carries no code edge to `firefly/security` and so cannot say who asked; a `POST` to it answers 404.
- No second management port (an Octane second-listener is an SP-7 option).
diff --git a/docs/modules/admin.md b/docs/modules/admin.md
new file mode 100644
index 0000000..c4cb463
--- /dev/null
+++ b/docs/modules/admin.md
@@ -0,0 +1,385 @@
+# Admin Dashboard
+
+`firefly/admin` is a server-rendered browser dashboard over the actuator — the Spring Boot Admin analogue, with one
+structural difference: it is not a separate monitoring application you deploy and register instances with. It is
+Blade views *inside* the application it reports on, which is why it can read the endpoint registry directly, and
+why its access model matters as much as it does.
+
+It arrives with the runtime family — `firefly/firefly` requires it, so a `composer create-project
+firefly/skeleton` project already has it and `firefly new --with admin` only makes the dependency explicit in
+your own `composer.json`. Add it directly if you took the packages à la carte:
+
+```bash
+composer require firefly/admin
+```
+
+Then open `/firefly`. **Installed is not enabled**: `firefly.admin.enabled` defaults to `app.debug`, so the
+package being present costs a production deployment nothing. That default, not the absence of the package, is
+what stands between the dashboard and the internet — which is why the warning below matters more than the
+install line above.
+
+!!! warning "The access model is the whole security model"
+ `firefly.admin.enabled` defaults to `app.debug`, because the dashboard bypasses the actuator's
+ exposure model and its own URL is therefore the only thing in front of `beans`, `env` and
+ `conditions`. It ships **no authentication of its own**. Read
+ [Access: the whole security boundary](#access-the-whole-security-boundary) before enabling it
+ outside debug.
+
+## The pages
+
+Most pages are a view over one `ActuatorEndpoint`'s payload; four read the container instead. The menu groups
+them the way an operator thinks rather than the way the packages are laid out — *what is it doing right now*,
+*what did it wire at boot*, *what is its data*, *how is it configured* — because a flat list of seventeen links
+is a worse menu than four short ones.
+
+| Group | Page | Path | Endpoint | Answers |
+|---|---|---|---|---|
+| Runtime | Overview | `/firefly` | several | Is it healthy, what is it doing, and what did it wire? |
+| Runtime | Health | `/firefly/health` | `health` | Every indicator this process registered, with its own status and details |
+| Runtime | Metrics | `/firefly/metrics` | `metrics` | Counters, timers and gauges, with their current measurements |
+| Runtime | HTTP traffic | `/firefly/http` | `httpexchanges` | The most recent requests this application served |
+| Wiring | Beans | `/firefly/beans` | `beans` | Every bean the container registered, with the stereotype that declared it |
+| Wiring | **Bean graph** | `/firefly/graph` | `beans` | How your beans depend on one another — see [Bean Graph](bean-graph.md) |
+| Wiring | Conditions | `/firefly/conditions` | `conditions` | Which auto-configurations applied, and which backed off because you supplied your own |
+| Wiring | Routes | `/firefly/mappings` | `mappings` | The compiled route table the dispatcher serves from |
+| Wiring | Scheduled | `/firefly/scheduled` | `scheduledtasks` | Methods registered by `#[Scheduled]`, with the cron or interval that drives them |
+| Configuration | Environment | `/firefly/env` | `env` | Resolved `firefly.*` configuration, flattened to dotted keys, with secrets masked |
+| Configuration | Config properties | `/firefly/configprops` | `configprops` | Every `#[ConfigProperties]` DTO the application bound, with the values it resolved |
+| Configuration | Caches | `/firefly/caches` | `caches` | The cache stores this application has configured |
+| Configuration | Loggers | `/firefly/loggers` | `loggers` | Log channels and their levels, with a control to change one |
+| Configuration | **Feature switches** | `/firefly/settings` | — | Every framework switch, where its value came from, and — outside production — a control. **Off by default**; see [below](#the-feature-switch-console) |
+| Data | **Datasource** | `/firefly/datasource` | — | Connections, connection reuse, and the compiled `#[Transactional]` contract |
+| Data | **Browse data** | `/firefly/data` | — | The records behind your repositories — see [Data Browser](data-browser.md). **Off by default** |
+| Data | **Entity map** | `/firefly/data-map` | — | The entities and the foreign keys between them, drawn |
+
+The four pages with no endpoint read the container rather than the actuator, and each decides its own visibility:
+an entry that led to "there is nothing here" is worse than no entry.
+
+A page whose endpoint is **not registered in this process** — or is switched off — is *hidden from the menu* rather
+than offered as a link that lands on an apology, and requesting it directly answers 404 with a page saying which
+endpoint it needed. That matters because the actuator's endpoints are conditional: `metrics` disappears when
+`firefly.observability.metrics.enabled` is false, and `configprops`, `caches` and `httpexchanges` exist only if the
+package contributing them is installed. The menu has to be built from what this process actually registered, so it
+is.
+
+The Overview is the page an operator leaves open, so it answers the three questions that matter without a click: the
+aggregate health status with every indicator beside it, the `/actuator/info` runtime fragment flattened to one row
+per fact (with byte-ish keys formatted as sizes rather than printed as raw JSON), bean/route/condition/task counts,
+the current metrics, the last eight HTTP exchanges, and whether this process booted **compiled** or **scanned** —
+read from `AppScan::cachedFile(...)`, not from configuration.
+
+Values are formatted for reading, not for scraping: `2.0 MB` rather than `2097152`, `31.2 ms` rather than `0.0312`.
+That formatting lives in the dashboard, never in the endpoint, because the JSON surface has to keep returning
+machine-readable numbers — Prometheus scrapes it.
+
+## It reads endpoints in-process, not over HTTP
+
+`AdminEndpointReader` holds the `ActuatorRegistry` and invokes each `ActuatorEndpoint` bean directly:
+
+```php
+public function read(string $id, array $subPath = [], array $query = []): ?array
+{
+ $endpoint = $this->registry->get($id);
+ if ($endpoint === null || ! $this->has($id)) {
+ return null;
+ }
+
+ try {
+ $response = $endpoint->handle(new EndpointRequest('GET', $subPath, $query));
+ } catch (Throwable) {
+ return null;
+ }
+
+ return $response === null || is_string($response->body) ? null : $response->body;
+}
+```
+
+What is **not** in that method is any mention of `ExposureModel`, and that is the single most important thing about
+this package. `firefly.management.endpoints.web.exposure.include` defaults to `health,info`, so fetching
+`/actuator/beans` or `/actuator/env` over HTTP 404s — [as it should](actuator.md).
+The dashboard needs none of that. It renders what the process already knows, in-process, so **it shows pages the
+HTTP surface deliberately does not expose**, and the JSON surface stays secure-by-default. Exposing `beans`,
+`conditions` and `env` to every anonymous caller just so a browser could read them would be exactly the wrong trade.
+
+The per-endpoint kill switch **is** honoured, and the asymmetry is the design:
+
+| Key | Means | Dashboard |
+|---|---|---|
+| `firefly.management.endpoint.{id}.enabled` | "this endpoint is off" — a statement about the endpoint | honoured; the page disappears from the menu |
+| `firefly.management.endpoints.web.exposure.include` | "this endpoint is unpublished" — a statement about the HTTP surface | **bypassed**; the dashboard is not the HTTP surface |
+
+A throwing endpoint is caught and reported as `null` rather than allowed to take the page down with it — the same
+fail-safe discipline `HealthEndpoint` applies to indicators, for the same reason: one broken contributor should
+degrade its own panel, not the dashboard.
+
+### Health details are read from the contributor registry
+
+`firefly.management.endpoint.health.show-details` defaults to `never`, and that default is right: it stops an
+anonymous HTTP caller learning your database host from a failed connection. Applying that *HTTP disclosure policy*
+to the dashboard, though, produced a Health panel whose entire content was an apology telling the operator to go
+and change a config key.
+
+The dashboard reads `HealthContributorRegistry` directly instead, calling each indicator in isolation so one that
+throws is reported `DOWN` with its exception class and message and nothing else is affected — exactly what
+`HealthEndpoint`'s own fail-safe read does. The JSON `/actuator/health` response is unchanged and still withholds
+components until `show-details` is `always`.
+
+## Access: the whole security boundary
+
+Because the dashboard bypasses exposure, **its own URL is the only thing standing in front of `beans`, `env` and
+`conditions`.** That is why it must not be on by default in production, and why the enable flag is written the way
+it is:
+
+```php
+enabled: $config->bool('firefly.admin.enabled', $config->bool('app.debug', false)),
+```
+
+`firefly.admin.enabled` **defaults to the value of `app.debug`**. An application already running with debug on is
+already serving stack traces to whoever asks and is a development environment by definition, so a dashboard there
+discloses nothing that was not already disclosed. An application with debug off has made the opposite statement
+about itself and must opt in explicitly. **Setting the key always wins over the debug default, in both directions**
+— you can turn the dashboard off in a debug environment, and on in a production one.
+
+!!! warning "Turning it on outside debug is only half the job"
+ `firefly.admin.enabled = true` with `app.debug = false` mounts a dashboard that renders your bean graph, your
+ resolved configuration and your route table at a known URL, to anyone who can reach it. **The dashboard ships
+ no authentication of its own** — it has no code dependency on `firefly/security` at all, exactly as
+ `firefly/actuator` does not. An application that enables it outside debug **must put the route behind its own
+ auth middleware.**
+
+`firefly/security`'s `HttpSecurityFilter` is a global middleware pushed onto Laravel's HTTP-kernel stack, so it runs
+for the dashboard's natively-registered routes exactly as it runs for your controllers. Locking it down is pure
+configuration:
+
+```php
+'firefly' => [
+ 'admin' => [
+ 'enabled' => true, // explicit: this deployment wants the dashboard with app.debug off
+ 'base-path' => '/firefly',
+ ],
+ 'security' => [
+ 'enabled' => true,
+ 'http' => [
+ 'enabled' => true,
+ 'rules' => [
+ ['pattern' => 'firefly', 'access' => 'hasRole:ADMIN'],
+ ['pattern' => 'firefly/*', 'access' => 'hasRole:ADMIN'],
+ ],
+ ],
+ ],
+],
+```
+
+Both patterns are needed: `firefly` alone does not match `firefly/env`. Any other middleware works equally well —
+a VPN-only route group, basic auth, an SSO gateway — the requirement is that *something* stands in front of the
+path, not that it be `firefly/security`.
+
+When the dashboard is disabled, `AdminRouteRegistrar` registers **nothing at all**: there is no route to guess at
+and no handler to reach, and `php artisan route:list` does not list one.
+
+### The write surfaces are CSRF-protected, and were not
+
+The dashboard's routes were mounted on the router with **no middleware at all**, which in Laravel means no
+session and no `ValidateCsrfToken`. Every `@csrf` in these views was therefore decorative: a `curl -X POST`
+with no token against `/firefly/loggers` was accepted and changed the log level, and the same held for every
+write the data browser and the settings console added.
+
+A form that renders a CSRF field while the route ignores it is **worse than one that renders none**, because
+it looks protected. The routes now carry `EncryptCookies`, `AddQueuedCookiesToResponse`, `StartSession`,
+`ShareErrorsFromSession` and `ValidateCsrfToken`.
+
+!!! note "The classes, not the `web` group name"
+ Naming the group and guarding on `hasMiddlewareGroup('web')` looked right and attached **nothing**: this
+ registrar runs inside the framework's boot pipeline, *before* the application's `RouteServiceProvider`
+ defines that group, so the guard was false at registration time and silently produced an empty list — a
+ fix that appeared applied and was not. Referring to the classes needs no group and no ordering
+ assumption, and each is skipped if the installation does not have it.
+
+!!! warning "Your session driver has to persist"
+ An `array` session driver is discarded at the end of the request, so the token a form renders can never
+ match the one the next request checks and **every** dashboard POST answers `419`. The skeleton now ships
+ `SESSION_DRIVER=file` for exactly this reason; `file` needs no service, only the storage directory the
+ framework already writes to.
+
+ Laravel's CSRF middleware returns early when `runningUnitTests()` is true, so no feature test can prove
+ this either way — which is how the hole survived being written. `tests/AdminCsrfTest.php` therefore
+ asserts the middleware is *attached*, and the behaviour was verified over real HTTP: tokenless → `419`,
+ token with its session → `302` and the write lands.
+
+## How it is mounted
+
+`AdminRouteRegistrar` is a `BootPass` at `BootPhase::WiringPasses`, order **60** — one step after
+`ActuatorRouteRegistrar`'s 50, because it reads the registry that pass populates. It mounts two routes:
+
+```
+GET {base} name: firefly.admin.index
+GET|POST {base}/{page} name: firefly.admin.page where page: [A-Za-z0-9\-_/]*
+```
+
+They are registered natively on the illuminate `Router`, not declared with `#[GetMapping]`, for the same reason the
+actuator's and [OpenAPI's](openapi.md#the-routes-are-not-attribute-routes) are: `firefly.admin.base-path` has to be
+settable per application, and an attribute route bakes its literal path into a compiled `RouteDescriptor`. Leading
+and trailing slashes on the configured base path are optional, and an empty one falls back to `firefly`.
+
+It also **backs off silently in one more case that is easy to miss.** Blade is required to render the dashboard and
+is *not* a dependency of the package, so a JSON-only deployment with no `view` binding gets no routes rather than
+routes that would fatal on first request; the JSON actuator remains the management surface there.
+
+## No build step
+
+The views are plain Blade with inline CSS and system fonts. There is no npm step at install time and no CDN at
+request time — a Composer package cannot assume npm has run, and a dashboard that needs the network is useless in
+exactly the isolated environments where you most want to look at one. (The one other browser surface LaraFly ships,
+[`firefly/openapi`](openapi.md)'s console, reaches the same conclusion by a different route: it serves the official
+Swagger UI from the application's own origin out of a composer package.)
+
+## Three things it can only tell you about *this* process
+
+Under PHP-FPM every request is a different process, and three pages inherit that.
+
+- **Changing a log level affects this process only.** The control calls the same endpoint
+ `POST /actuator/loggers/{name}` does, which mutates the current process's Monolog handlers. The next request is a
+ different process and reverts to the configured level. Change `logging.channels` for anything that must persist —
+ the page says so, in place, rather than letting anyone believe they have changed production logging.
+- **Metrics are only as durable as your registry.** The default `SimpleMeterRegistry` keeps meters in process
+ memory, so the dashboard sees only its own request. Set
+ [`firefly.observability.metrics.store`](observability.md#configuration-fireflyobservability-kebab-case) to a cache
+ store to accumulate across workers.
+- **HTTP traffic has the same shape, more sharply.** The in-memory exchange ring under PHP-FPM is not merely stale
+ but always empty, because the request rendering the page has not been recorded yet — the filter records on the way
+ out. `firefly.observability.httpexchanges.store` is what makes that panel non-empty. While you are there, add the
+ dashboard's own base path to `firefly.observability.httpexchanges.exclude`: a polling dashboard will otherwise
+ evict every genuine request from a 100-row ring and show you nothing but itself. The framework does not add it for
+ you, because reaching into another package's configuration key to guess at its mount point is the kind of hidden
+ coupling that breaks the day somebody changes it.
+
+## Configuration (`firefly.admin.*`)
+
+| Key | Default | Meaning |
+|---|---|---|
+| `firefly.admin.enabled` | **`app.debug`** | Mount the dashboard at all. An explicit value wins in both directions. |
+| `firefly.admin.base-path` | `'/firefly'` | Where it is mounted. Leading and trailing slashes optional; empty falls back to `firefly`. |
+| `firefly.admin.title` | `app.name` (else `'LaraFly'`) | The name shown in the sidebar and the page title. |
+| `firefly.admin.refresh-seconds` | `10` | How often a live page reloads itself. **Floored at 2**: a shorter interval reloads faster than the page renders, so the countdown would never finish and the dashboard would hammer the application it is meant to be observing. |
+| `firefly.admin.theme` | `'auto'` | `auto` \| `light` \| `dark`. Anything unrecognised falls back to `auto` (follow the operating system) rather than rendering unstyled. |
+| `firefly.admin.graph.max-nodes` | `220` | The ceiling past which the [bean graph](bean-graph.md) lists relations instead of drawing them. Clamped to a minimum of `0`, which suppresses the diagram entirely. |
+| `firefly.admin.pages.exclude` | `''` | CSV of page slugs to refuse. This is a **refusal, not a menu preference**: an excluded page is hidden *and* its URL 404s — hiding `env` from the menu achieves nothing if the URL still answers. Use `overview` for the index page. |
+| `firefly.admin.datasource.probe` | `true` | Whether the datasource page may **open** a configured connection to report that it answers. |
+| `firefly.admin.datasource.wizard` | **`false`** | The connection wizard. Off by default and refused in production — see [below](#the-connection-wizard). |
+| `firefly.admin.settings.enabled` | **`false`** | The feature-switch console. |
+| `firefly.admin.settings.writable` | **`false`** | Whether that console has controls. Ineffective in production. |
+
+The `firefly.admin.data.*` keys are documented separately, in [Data Browser](data-browser.md#configuration-fireflyadmindata),
+because the browser is gated independently of everything above: `firefly.admin.enabled` does **not** switch it on,
+and neither does `app.debug`.
+
+## The datasource page
+
+Four questions an operator asks at 3am that this dashboard could not answer:
+
+- **Which database am I talking to?** Driver, host, port and database per connection, with `password` masked by the
+ same masker the actuator's `env` endpoint uses — the page is behind the dashboard's gate, and a connection array
+ dumped verbatim would put the database password on that URL.
+- **Is it up?** *One* connection is probed per page load — the default, or the one named by `?probe=` — because
+ opening a socket can hang against a firewalled host, and a page that opened every configured connection would
+ take the slowest one's timeout to render, on the page you opened *because* something is wrong. What comes back
+ is the server version, or the driver's own message.
+- **What does pooling mean here?** PHP has no connection pool, and a "pool size" gauge would be an invented number.
+ What exists is PDO's `ATTR_PERSISTENT`, reported as what it is — with the note that under php-fpm the effective
+ pool size is your worker count, decided by the process manager, and that real pooling in front of Postgres is
+ pgbouncer's job.
+- **What did `#[Transactional]` compile to?** One row per proxied method with its propagation, isolation, timeout
+ and connection. It existed only as a compiled artifact under `bootstrap/cache`.
+
+### The connection wizard
+
+`firefly.admin.datasource.wizard` adds a form that opens a connection you have **not** configured yet and reports
+the server version or the driver's own error, plus the `config/database.php` block to paste. It collapses the
+edit-`.env`, clear-cache, reload, read-a-useless-error loop into one round trip.
+
+It is **off by default and refused outright when `app.env` is production**, and no configuration key lifts that. A
+form that opens a socket to a host somebody typed is a request-forgery primitive by construction, and its failure
+messages distinguish "refused" from "timed out" well enough to map a private network. It is POST-only for the same
+reason — a link, an image tag or a prefetch must never be able to reach it — and it **writes nothing**: the result
+is a snippet, with the password always an `env()` call and never the value that was typed.
+
+!!! note "Why the errors are useful at all"
+ The connection is opened with `getPdo()` *before* the test query. Going through `selectOne()` puts Laravel's
+ reconnect wrapper in the way, which rethrows `Lost connection and no reconnector available` for a wrong
+ password, a closed port and a typo in the host alike. Forcing the socket and unwrapping to the innermost
+ exception is what turns the button into something worth pressing.
+
+## The entity map
+
+`/firefly/data-map` draws every browsable entity as a box with its columns, and every foreign key as a labelled
+edge, from the same discovery the [data browser](data-browser.md#relations) walks. Boxes are links into their own
+records.
+
+A `hasMany` and the `belongsTo` facing it are **one** key seen from two ends, so each is drawn once — pointing from
+the table that *holds* the key to the table it references, which is also what the arrow means. Relations the
+browser cannot express as a single column comparison (a pivot, a polymorphic type column) are listed on each record
+page but are not drawn, because a line with no join to name would be decoration. Entities with no relations at all
+*are* drawn: a standalone table is a fact about the model, and a diagram that quietly dropped it would let a reader
+conclude the application has fewer tables than it does.
+
+It is behind the browser's own switch, not the dashboard's: a schema diagram names every table and column an
+application has, which is the shape of its data even though it is not the data.
+
+## The feature-switch console
+
+`/firefly/settings` is the one page that **changes** the application rather than describing it, and it is gated
+accordingly.
+
+| Gate | Default | What it decides |
+|---|---|---|
+| `firefly.admin.settings.enabled` | `false` | Whether the page exists at all |
+| `firefly.admin.settings.writable` | `false` | Whether it has controls as well as readings |
+| `app.env` is `production` | — | **Not a configuration key.** Writes are refused, whatever the two above say |
+
+The third gate is deliberately unconfigurable. That is the difference between "we made it safe" and "we made it
+configurable to be safe", and only the first survives someone copying a `.env`.
+
+**It is a feature switch, not a remote configuration endpoint.** The list of switches is fixed and framework-owned,
+so a crafted POST naming `app.key` or a database host finds nothing to write — the method cannot express it. Each
+row shows where its value came from: `config` (yours), `default` (the framework's), or `console` (this page).
+
+A change is written to **one** JSON file under `bootstrap/cache`, filtered on the way in as well as out — a
+hand-edited entry cannot introduce a key the console would have refused — and merged over configuration during the
+provider's `register()`. Deleting that file restores your configured values exactly. Nothing is ever written to
+`.env`: a config cache would disagree with it until someone cleared it, the file is routinely read-only in a
+container image, and a web form that edits the file holding your database password is not a feature.
+
+!!! note "Why `register()` and not a boot pass"
+ Every settings object in this framework is built once from configuration and held for the process. Applying the
+ overrides from the dashboard's own boot pass wrote the file and showed the new state on the page while
+ `/openapi.json` kept answering 200 — a merge after the first read changes nothing. `register()` runs before any
+ boot pass and before any bean resolves, which is the only point at which the merge is true.
+
+## Laravel comparison
+
+| Concern | Plain Laravel | LaraFly (`firefly/admin`) |
+|---|---|---|
+| A management UI | none first-party; Telescope is a *request* debugger, Horizon a *queue* dashboard — neither reports on wiring or configuration | one dashboard over the actuator's own endpoints |
+| Browsing your data | none; Nova and Filament are paid or app-scale admin *frameworks* you build screens in | a Django-style browser over the repositories you already declared, off by default |
+| Feature switches | a config file and a deploy | a gated console, with the production gate not configurable |
+| Where it runs | Telescope/Horizon each add tables, a service provider and a middleware group | Blade views over beans that already exist; no storage of its own, nothing recorded |
+| Data source | a recorder writing to the database | the live `ActuatorRegistry`, read in-process at render time |
+| Enabling it safely | `TelescopeServiceProvider::gate()` — a closure you write | `firefly.admin.enabled` defaulting to `app.debug`, plus your own middleware when you override it |
+
+## Known-latent
+
+- **No instance registry.** Spring Boot Admin is a separate server that many applications register *with*, giving
+ one console across a fleet. This is a per-instance dashboard, which is what makes the in-process read possible;
+ a fleet view would need a different design and is not planned.
+- **No write operations besides the log level, the data browser and the feature switches.** `/caches` is read-only
+ for the same reason it is read-only on the JSON surface — `firefly/actuator` carries no code edge to
+ `firefly/security` and so cannot say who asked.
+- **`when-authorized` health details** degrade to `never` on the JSON surface (see
+ [Actuator](actuator.md#known-latent)); the dashboard sidesteps it entirely by reading the contributor registry.
+
+---
+
+See also: [Actuator](actuator.md) for the endpoints themselves, [Observability](observability.md) for the metrics
+and HTTP-exchange stores the dashboard renders, [Bean Graph](bean-graph.md) for the one page that is more than
+a table, and [Data Browser](data-browser.md) for the Django-style view over your own repositories — which is
+**off by default and does not inherit `firefly.admin.enabled`**.
diff --git a/docs/modules/bean-graph.md b/docs/modules/bean-graph.md
new file mode 100644
index 0000000..4f31f29
--- /dev/null
+++ b/docs/modules/bean-graph.md
@@ -0,0 +1,199 @@
+# Bean Graph
+
+The bean graph is the one page of the [admin dashboard](admin.md) that is more than a table: a layered, drawn
+diagram of how your beans depend on one another, at `/firefly/graph`.
+
+It answers a question `/actuator/beans` cannot. That endpoint tells you *which* beans exist; the graph tells you
+what each one is **wired to**, which is what you actually want when a `#[ConditionalOnMissingBean]` did not fire the
+way you expected, when a cycle has hung a boot, or when you are trying to work out what a package you just
+installed attached itself to.
+
+```
+composer require firefly/admin # already required by firefly/firefly; the graph is a page of the dashboard, not a package of its own
+```
+
+## What counts as a node
+
+A LaraFly application has **three kinds of bean**, and all three are nodes:
+
+| Kind | What it is | Where the node comes from |
+|---|---|---|
+| `component` | A scanned `#[Component]`/`#[Service]`/`#[Repository]`/`#[RestController]`/`#[Configuration]` class | The beans catalogue |
+| `bean` | A **value produced by a `#[Bean]` factory method** on a `#[Configuration]` | The `produces` rows of the catalogue |
+| `config` | A `#[ConfigProperties]` DTO bound from configuration | The `configprops` endpoint |
+
+That list is the whole design, and it is worth saying why, because the first version of this page only knew about
+the first kind and was therefore *structurally incapable* of showing framework wiring.
+
+A framework's wiring lives almost entirely in the second kind. An auto-configuration is a `#[Configuration]` whose
+`#[Bean]` methods produce `MeterRegistry`, `TransactionTemplate`, `AggregateTracker` and so on. When only declaring
+classes were nodes, every edge pointing at one of those products pointed at a node that did not exist. Measured on
+a stock skeleton: **42 nodes, 41 `#[Bean]` products missing, 21 dangling dependencies, and exactly one edge drawn.**
+The graph was not sparse — it was a field of disconnected dots with the mechanism removed.
+
+The third kind is a smaller version of the same mistake. A `#[ConfigProperties]` DTO is bound and injectable but is
+neither scanned as a component nor produced by a factory, so nothing in the beans catalogue can see it: it showed
+up as an *unresolved dependency* of the service that injects it rather than as the bean it is. It is read from the
+`configprops` endpoint alongside the catalogue for exactly that reason.
+
+### The identity of a `#[Bean]` product
+
+Usually the produced **type** is the identity, because that is the key the container binds and the key every
+consumer asks for. `MeterRegistry` is the node; the `#[Configuration]` that made it is recorded on the node as a
+detail (`ObservabilityAutoConfiguration::meterRegistry()`), not as its name.
+
+When **two factory methods produce the same type** — the shape that requires `#[Primary]`/`#[Qualifier]` to
+disambiguate — the type alone would collapse them into one node and hide exactly the ambiguity the reader came to
+look at. So each competitor gets `Declaring::method()` as its id, and the bare type resolves to the first of them.
+That mirrors the container itself, where the type key aliases the winner and every candidate stays reachable by
+name.
+
+## What counts as an edge
+
+Two kinds, and they mean different things:
+
+| Edge | From → to | Meaning |
+|---|---|---|
+| `injects` | A bean → something it declared a dependency on | The consumer asked for it; the container satisfies it |
+| `produces` | A `#[Configuration]` → the value one of its `#[Bean]` methods returns | This class is where that bean comes from |
+
+`injects` edges are drawn for a component's **constructor** parameters *and* for a `#[Bean]` **factory method's**
+parameters — the product depends on what its factory asked for. That union is where a framework's wiring actually
+lives, and a graph built from constructors alone draws almost nothing.
+
+Nothing is reflected at request time to work any of this out. `ComponentScanner` records the types at **scan** time
+and they ride the compiled manifest exactly like every other scanned fact:
+
+```php
+/**
+ * The class types this component's constructor asks for — the edges of the bean graph.
+ *
+ * Recorded at scan time, where reflection is already sanctioned, because the alternative is
+ * reflecting at request time to answer "what depends on what", which the reflection-free boot
+ * contract forbids. Only CLASS and INTERFACE types are kept: a scalar or a builtin is configuration,
+ * not a wiring edge, and putting it in the graph would drown the edges that matter.
+ */
+public array $dependencies = [],
+```
+
+A `string $name` parameter is configuration, not wiring, and is not an edge. A **nullable or defaulted** class
+parameter *is* an edge — an optional collaborator is still a relationship.
+
+## Why an edge through an interface is labelled with that interface
+
+A constructor asks for a **type**, and that type is very often an interface — `EventPublisher`,
+`HealthIndicator`, `Cache` — while the bean that satisfies it is a concrete class, or the return of a factory
+method. An edge list built naively from declared types therefore points at nodes that do not exist.
+
+So every dependency is resolved through an index of *what satisfies what* — a component's `interfaces`, and every
+`#[Bean]` method's produced type — before it becomes an edge. `PostgresEventPublisher` is what `EventPublisher`
+links to.
+
+The edge then records the interface it went through, in a member called **`via`**, and both surfaces show it: the
+diagram's edge `` reads `Consumer → Target (via EventPublisher)`, and the **Relations** table has a
+*Wired by* column naming the interface, or `—` when the constructor named the concrete type.
+
+That label is the honesty in the whole page. Without it the reader is shown a relationship they never wrote —
+`WalletService → EloquentWalletRepository` is *true*, but what they wrote was `WalletRepository`, and the gap
+between the two is precisely where a mis-wiring hides. With it, the indirection is visible and the port they
+depend on is named.
+
+The index is built in catalogue order and **first implementor wins**, deterministically — the catalogue is emitted
+in scan order, so the same application always draws the same graph rather than reshuffling between machines. An
+interface with several implementors is a real ambiguity that the container resolves with `#[Primary]`/`#[Qualifier]`,
+and the graph says so by listing the edge as `via` rather than pretending the choice was obvious.
+
+## Layers, and why arrows read downward
+
+Level assignment is a **longest-path** walk over the resolved edges: a node's depth is one more than the deepest
+thing it depends on, and the levels are then flipped so that level 0 holds the things nothing depends on. The
+result is that a node always sits below everything that depends on it, arrows flow consistently downward, and the
+eye can follow a chain from a controller to the repository at the bottom of it.
+
+Within a level the nodes are **clustered by module** — the first two namespace segments, `Firefly\Observability`,
+`App\Http` — so related things end up adjacent rather than scattered, and each module gets a stable colour assigned
+by position, with a legend whose entries toggle. Hues are kept away from the red and green the rest of the
+dashboard reserves for status.
+
+Each level is then **wrapped into a grid of its own** rather than laid out as one row. A pure layered layout is
+wrong for this graph: dependency depth is shallow and wide, so most beans land on one or two levels — a stock
+skeleton produced a single row 54 nodes and 9184px across, which the fit-to-view control then scaled to 11%, i.e.
+unreadable. Wrapping keeps the drawing a compact rectangle while arrows still read downward from dependents to
+dependencies.
+
+## Cycles are reported, not fatal
+
+Depth is memoised and the walk carries its own visited set, so a **cycle terminates instead of recursing forever**.
+The edge that closed it is collected, and when the walk finds one a *Circular dependencies* panel appears above the
+diagram listing every closing edge, with the **Cycles** stat turned red.
+
+Reporting rather than throwing is the deliberate choice, and it is worth being explicit about why. A cycle is a
+fact about *your application*, not a malfunction of the page that drew it — and the page is very often the only
+thing that can tell you. The container has no cycle detection of its own, so a cycle among eager singletons does
+not produce a helpful error: it exhausts memory at boot. If this page refused to render on finding one, the single
+tool capable of naming the two classes involved would go dark at exactly the moment you needed it, and you would be
+back to a process that died with no message.
+
+So it renders, draws everything else, and names the closing edges. The panel's own advice is the right one: break
+one of these edges, usually by depending on an interface and letting the other side provide it.
+
+## The panels
+
+| Panel | Shows | Notes |
+|---|---|---|
+| Stats | Beans, Components, `#[Bean]` products, Config DTOs, Relations, Layers, Cycles | Cycles renders as a red chip when non-zero |
+| Circular dependencies | Every closing edge, `Bean` → `Depends on` | Only rendered when there is at least one |
+| Wiring | The layered SVG diagram — module legend with toggles, a find box, Fit/Reset controls, drag-to-pan and scroll-to-zoom, and an inspector panel showing a selected bean's dependencies and dependents | Suppressed past the node ceiling |
+| Relations | Every edge as `Bean` / `Depends on` / `Wired by` | Always rendered, filterable — the fallback when the diagram is suppressed |
+| Provided outside the container | Declared types nothing in the container provides | Chips, with the full type as a tooltip |
+
+Each node is a rounded box carrying the bean's short name, its kind, and its in/out degree; its `` carries
+the fully-qualified identity and, for a `#[Bean]` product, the factory method that produced it. Edges are curves
+with an arrow marker, styled by edge type, and an edge that went through an interface carries the interface in its
+title.
+
+The diagram is plain inline SVG generated server-side — no JavaScript graph library, no layout engine, no network
+request. It is the same "no npm step, no CDN" rule the [rest of the dashboard](admin.md#no-build-step) follows.
+
+## Two honest limits
+
+**Past 220 nodes the diagram is suppressed.** The ceiling is `firefly.admin.graph.max-nodes`, default `220`; above
+it the Wiring panel says so and the Relations table below carries the same information as a filterable list. A
+diagram past a couple of hundred nodes is a hairball, not something a person can read, and rendering one anyway
+would be a worse answer than declining to. It is configurable because "unreadable" depends on the screen and the
+application — raise it to draw a bigger graph anyway, or set it to `0` to always get the list.
+
+**"Provided outside the container" is not a warning.** Those are declared types satisfied by a Laravel container
+binding rather than a bean — the `Request`, the config repository, a database connection, a framework contract.
+They are listed rather than silently dropped precisely because *"why is my bean not in the graph"* is the question
+this page has to be able to answer. A type appearing there is usually correct; a type appearing there that you
+expected to be a bean of yours means your scan did not see it, and `firefly.scan.paths` is the first thing to
+check.
+
+## Reading it against the Conditions page
+
+The graph and [Conditions](admin.md#the-pages) answer complementary questions, and the pair is the fastest
+way to diagnose an auto-configuration surprise:
+
+1. **Conditions** says *whether* a framework bean was registered or backed off, and on which condition.
+2. **The graph** says what the bean that *did* win is wired to, and through which interface.
+
+An `EventPublisher` edge pointing at `InMemoryEventPublisher` when you configured `firefly.eda.provider=rabbitmq`
+is visible in one glance on the graph, and Conditions then tells you which `#[ConditionalOnProperty]` did not match.
+
+## Known-latent
+
+- **`#[Primary]`/`#[Qualifier]` do not steer the index.** First writer in scan order wins, both for an interface
+ with several implementors and for the bare type key of a contested `#[Bean]`. Every competitor still gets its own
+ node and the edge is marked `via`, so the ambiguity is visible — but the drawn target may not be the one the
+ container resolves.
+- **No crossing minimisation.** Nodes are ordered within a level by module and then label, and levels are wrapped
+ into grids; there is no pass that reorders them to reduce edge crossings, so a dense graph has crossing edges.
+- **An unresolved type is reported, never explained.** The page can say a type is provided outside the container;
+ it cannot say by *which* binding, because a Laravel container binding carries no descriptor to read.
+
+---
+
+See also: [Admin Dashboard](admin.md) for the page's access model, [Dependency Injection](dependency-injection.md)
+for what the stereotypes and scopes on each node mean, and [Auto-Configuration](starters.md) for the conditions
+that decided which beans exist at all.
diff --git a/docs/modules/configuration.md b/docs/modules/configuration.md
index 59a029b..2b64a31 100644
--- a/docs/modules/configuration.md
+++ b/docs/modules/configuration.md
@@ -17,8 +17,58 @@ $profiles->isActive('prod'); // bool
$profiles->all(); // list
```
-`#[Profile('prod')]` marks a component as active only under a given profile (enforced by conditional
-registration in a later milestone).
+### Where each setting is read from
+
+`ProfileResolver` consults three sources per setting, in this order, and treats a blank or non-scalar value
+at any level as absent:
+
+1. **`Illuminate\Support\Env`** — the reader behind Laravel's `env()` helper. It sees `$_ENV`, `$_SERVER`
+ and `putenv()` values, so PHPUnit `` entries, `docker --env`, `php-fpm` `env[]` and a parsed `.env`
+ all resolve here. A real environment variable is the most specific signal available, so it wins.
+2. **The config repository** — `firefly.profiles.active`, then `app.env`. A list is accepted here, because
+ `['prod', 'eu']` reads far better in a PHP config file than `'prod,eu'`; both spellings converge.
+3. **Raw `getenv()`** — last resort, for a process that called `putenv()` after Env's repository was built,
+ or that runs with no Laravel application at all.
+
+Reading `getenv()` *only* — which is what this used to do — collapsed profiles to `['default']` in exactly
+the two places they matter most. Under `orchestra/testbench` the environment is set on the config repository
+and `putenv()` is never called, so a test asserting that a `#[Profile('test')]` bean is registered watched it
+silently not be. And under `php artisan config:cache`, Laravel's `LoadEnvironmentVariables` bootstrapper
+returns early, so `.env` is never parsed while the cached repository holds the correct `app.env` the whole
+time — profiles switched themselves off in production the moment an app followed the deployment guide.
+
+### `#[Profile]` gating
+
+`#[Profile('prod')]` on a `#[ConfigProperties]` DTO means the DTO is bound **only** when one of the named
+profiles is active. Multiple names are OR, never AND:
+
+```php
+use Firefly\Config\Attributes\ConfigProperties;
+use Firefly\Config\Profile\Profile;
+
+#[Profile('prod', 'staging')]
+#[ConfigProperties('payments')]
+final readonly class PaymentsProperties
+{
+ public function __construct(public string $gatewayUrl) {}
+}
+```
+
+The chain is: `ProfileRequirement` reads the attribute **once, at scan time**; `ConfigPropertiesScanner`
+records the result on the descriptor; the compiled `config-properties.php` carries it; and `ConfigRegistrar`
+skips the binding when the profiles are not active. Nothing reflects a user class at boot to discover the
+gate, and an excluded DTO simply **does not exist** — injecting it fails loudly at resolution time rather
+than quietly handing back configuration that was meant to be unreachable.
+
+Until this landed the attribute was pure decoration: it was exported and documented, `grep -rn 'Profile::class'
+packages/*/src` matched zero lines of production code, and a class marked `#[Profile('prod')]` was registered
+under every profile including the ones the annotation exists to exclude.
+
+**For a non-DTO bean** — anything that is not `#[ConfigProperties]` — use `firefly/context`'s
+`#[ConditionalOnProfile]` instead. It is the same predicate, already wired into `ConditionEvaluator`.
+Gating a general `#[Component]` with `#[Profile]` additionally needs `firefly/context` to record the
+requirement while it scans, and `firefly/config` sits below Context in the layer graph, so it cannot reach
+up to do it.
## Typed access
@@ -55,6 +105,43 @@ The DTO is registered as a container singleton bound from `config('mail')`, so i
sub-arrays. Discovery compiles to a cached manifest (Octane-safe). The binder sits behind a `ConfigBinder`
seam, so a richer binder can be swapped in without touching your DTOs.
+### Relaxed binding
+
+A constructor parameter is **not** matched by its exact name alone. Each one is looked up under four
+spellings, in this fixed precedence order — the same relaxed binding Spring Boot performs:
+
+| # | Spelling | Example for `$dailyTransferLimitMinor` |
+|---|---|---|
+| 1 | exact parameter name | `dailyTransferLimitMinor` |
+| 2 | `snake_case` | `daily_transfer_limit_minor` |
+| 3 | `kebab-case` | `daily-transfer-limit-minor` |
+| 4 | `SCREAMING_SNAKE_CASE` | `DAILY_TRANSFER_LIMIT_MINOR` |
+
+The order depends only on the parameter name, never on the iteration order of the config array, so binding
+stays deterministic even when an array carries two spellings of the same property at once. Duplicate
+spellings collapse — a parameter already written in snake_case yields two candidates, not four.
+
+This exists because the two worlds otherwise never met. A `config/*.php` file is written by hand in whatever
+casing the application's house style prefers, and its values very often arrive from environment variables,
+which are `SCREAMING_SNAKE` by convention; a PHP constructor parameter is camelCase because PSR-12 says so.
+Matching only the exact name meant `'daily_transfer_limit_minor' => 250000` bound **nothing** onto
+`public int $dailyTransferLimitMinor` — and since an unmatched parameter with a default is not an error, the
+DTO came out holding the default. No exception, no log line, no failing test, just a wrong limit in
+production. This repo's own book shipped exactly that example, which is how the defect was caught.
+
+Two details worth knowing:
+
+- **Acronyms survive.** The camelCase → snake_case step breaks a lower-or-digit → upper boundary *and* an
+ acronym running into a following word, so `$apiURL` becomes `api_url` and `$HTTPProxyHost` becomes
+ `http_proxy_host` — not `api_u_r_l` and `_h_t_t_p_proxy_host`, which nobody would ever type into a config
+ file.
+- **A present-but-null key does not stop the search.** `'port' => env('MAIL_PORT')` yields `null` when the
+ variable is unset — the ubiquitous Laravel idiom — so a `null` under the exact name must not mask a real
+ value written in snake_case. It is read as "not supplied", exactly as `Config::required()` reads it.
+
+A parameter with no matching key, no default and no nullable type throws a `ConfigurationException` naming
+the property, the class, and every key that was tried.
+
## `#[Value]` from config
With `firefly/config` installed, `#[Value]` injection resolves against config first, then the environment,
diff --git a/docs/modules/cqrs.md b/docs/modules/cqrs.md
index 6b3d790..2abaa38 100644
--- a/docs/modules/cqrs.md
+++ b/docs/modules/cqrs.md
@@ -123,8 +123,7 @@ edge): the bridge listens for `DomainEvent`s on the in-process `Context` dispatc
|---|---|---|
| `firefly.cqrs.default_destination` | `cqrs.events` | Fallback integration-event destination. |
| `firefly.cqrs.event_failure_strategy` | `log` | `log` (swallow) or `raise` (re-throw) on a post-commit publish failure. |
-| `firefly.cqrs.query.cache_ttl` | _(unset)_ | Reserved for the real query cache (firefly/cache). |
-| `firefly.cqrs.enabled` | `true` | Reserved — the auto-configuration is always-on in v1. |
+| `firefly.cqrs.query.cache_ttl` | _(unset)_ | Default TTL in seconds passed to `QueryCache::put()` for a `Cacheable` query result. **Unset means "no TTL"**, not zero — so leave the key out unless you want one. The shipped `QueryCache` is `NoOpQueryCache`; a real store arrives with `firefly/cache`. |
## Laravel comparison
@@ -140,8 +139,8 @@ edge): the bridge listens for `DomainEvent`s on the in-process `Context` dispatc
Read-model / projection scaffolding (→ `firefly/eventsourcing`), real authorization (→ M11), real query
cache (→ firefly/cache), CQRS metrics + health (→ M12), attribute-driven command validation, the fluent
builder / distributed-tracing ergonomics, and exactly-once outbox durability (→ SP-4) are deferred and
-documented honestly. As with `#[Transactional]`/`#[EventListener]`, **app-level `HandlerManifest` compilation
-via `firefly:cache` lands in M15** — until then an application (or its test suite) supplies its compiled
-manifest inline (run `HandlerScanner::scan()` + bind the resulting `HandlerManifest`) rather than through an
-automated cache-warm command; a handler absent from the bound manifest simply never registers. Under
-Octane, `CorrelationContext` is per-request state and the `HandlerRegistry` is rebuilt per worker boot.
+documented honestly. The `HandlerManifest` needs no hand-wiring: like every other
+compiled manifest it resolves to the `firefly:cache` artifact if present, otherwise an in-process scan of
+`firefly.scan.paths`, otherwise empty — so an uncached app registers the same handlers a cached one does. A
+handler outside `firefly.scan.paths` still never registers. Under Octane, `CorrelationContext` is
+per-request state and the `HandlerRegistry` is rebuilt per worker boot.
diff --git a/docs/modules/data-browser.md b/docs/modules/data-browser.md
new file mode 100644
index 0000000..80a1d78
--- /dev/null
+++ b/docs/modules/data-browser.md
@@ -0,0 +1,438 @@
+# Data Browser
+
+The data browser is a Django-admin-style view over your application's own data, built on top of the LaraFly
+[data layer](data.md) and shipped with the [admin dashboard](admin.md).
+
+It is **off by default, and it does not inherit the dashboard's default.** Read
+[The two gates](#the-two-gates) before you switch it on — that section is the point of this page.
+
+!!! tip "Two switches, and neither follows `app.debug`"
+ `firefly.admin.data.enabled` turns the browser on and `firefly.admin.data.writable` permits writes on top
+ of it. Both default to **false** and neither is implied by `app.debug` or by `firefly.admin.enabled` —
+ see [The two gates](#the-two-gates), which is the point of this page.
+```bash
+composer require firefly/admin # already required by firefly/firefly; the browser is a part of the dashboard, not a package of its own
+```
+
+`DataBrowser` is the single entry point, and `DataBrowser::forContainer($container)` assembles one from the
+application container in a line. Discovery, schema derivation, reads and the two writes all go through it, and
+every one of them is behind the gates below.
+
+## What it discovers
+
+Nothing is registered, declared or configured. The browsable resources are **every bean whose scan-time
+interface list contains `CrudRepository`** — which means a repository you wrote is browsable the moment the
+container has it, and one you delete stops being browsable without anyone editing a list.
+
+The source is `BeansCatalog`, the boot-time snapshot of the condition-filtered bean registry — the same rows
+`/actuator/beans` serves. Each row already carries `class`, `stereotype` and the **full interface closure**
+`ComponentScanner` recorded with `class_implements()` at scan time, so "is this bean a repository, and does it
+also page?" is two `in_array()` calls over data the process already holds.
+
+Re-deriving that by reflecting over every registered class at request time would be slower, would break the
+framework's reflection-free boot contract for no gain, and — the decisive point — would find classes the
+**container never registered**, so the menu would offer resources that cannot be resolved. The catalogue is the
+definition of *what this application actually wired*, which is exactly the question a browser is asking.
+
+Each discovered resource carries the capability flags every later path branches on:
+
+| Flag | Means | Consequence |
+|---|---|---|
+| `paged` | The repository implements `PagingAndSortingRepository` | A page can be asked for by page number; the database does the offset, limit, `ORDER BY` and `COUNT` |
+| `eloquent` | It is an `EloquentRepository` whose `$model` resolved to a real model class | Schema-derived columns, SQL-side search and sort, and any write at all |
+
+A resource with neither is still listable — it is just expensive and read-only.
+
+### Slugs are derived from the class, not from a counter
+
+A slug is what addresses a resource — in a URL an operator bookmarks, in a link another page renders — so it
+must not move because an unrelated repository was added. The base slug is the kebab-cased short name of the **entity** (falling back
+to the repository's own name with the conventional `Eloquent` prefix and `Repository` suffix stripped), which
+depends on nothing but that class.
+
+A genuine collision — two `Wallet` entities in different namespaces — is resolved by **qualifying both sides**
+with their full namespace rather than by suffixing one with `-2`. An index suffix depends on scan order, so the
+loser's URL would change if the winner were ever removed; a namespace-qualified slug is a property of the class
+alone. Labels are disambiguated the same way, because a menu with two entries both reading "Wallet" is not a
+menu.
+
+## The two gates
+
+### `firefly.admin.data.enabled` defaults to **`false`**
+
+The dashboard itself follows `app.debug`, and [the argument for that](admin.md#access-the-whole-security-boundary)
+is sound *for what the dashboard shows*: beans, conditions, mappings and resolved configuration are facts about
+the **application**, and an application already serving stack traces has already published facts of that kind.
+
+This page shows facts about the application's **users**.
+
+That is a categorically bigger disclosure, and the routine mistakes that expose it are the same ones that expose
+nothing much today: a debug flag left on in a staging environment that shares a database with production, a
+`.env` copied to a box that was supposed to be internal, a developer laptop tunnelled for a demo. Each becomes a
+customer-record disclosure the moment a browser is wired to `app.debug`.
+
+So the gate is **separate, explicit, and off**. `app.debug` cannot switch it on, and neither can
+`firefly.admin.enabled`. Both of those must already be true **and** this key must be set:
+
+```php
+'firefly' => [
+ 'admin' => [
+ 'enabled' => true, // the dashboard itself
+ 'data' => [
+ 'enabled' => true, // ...and, separately, the data browser
+ ],
+ ],
+],
+```
+
+Disabled means **empty, everywhere**. The resource registry returns nothing when the key is off, even though
+every operation is gated again downstream. The redundancy is deliberate: the registry is public API a view could
+hold directly, and a discovery list that leaked the names of an application's entities while the browser was
+switched off would already be a disclosure.
+
+### Writes need `firefly.admin.data.writable` **on top of that**
+
+Also `false`, also its own key, and **ineffective on its own** — a write requires both.
+
+Reading the wrong row is a disclosure; deleting it is data loss with no undo, from a form, over a session that
+may be nothing more than "debug was on". Turning on the browser is a decision about **visibility**; turning on
+writes is a decision about **custody**. Collapsing the two into one key means the operator who wanted to look at
+a table also armed the delete button.
+
+A write attempted with only one gate set is **refused with a stated reason**, not silently ignored.
+
+## Create exists for Eloquent, and is refused for everything else
+
+The browser once had no `create()` at all, and the argument for that was half right.
+
+**An aggregate's constructor is where its invariants live** — an `Order` that must have at least one line, a
+`Wallet` whose balance starts at zero in the currency it was opened in, a value object that rejects a malformed
+IBAN — and a form built from a column list knows none of them. For a repository over a hand-written domain object
+there are only two ways to build the row and both are wrong: call the constructor, which needs arguments the form
+cannot supply in the right types or the right order; or write the columns straight to the table, which produces a
+row the domain model considers impossible and which every later read then has to cope with. The second is what a
+"just insert the columns" implementation actually does, and it is *worse than having no button*, because it looks
+like it worked. **That case is still refused, by name.**
+
+It was never true for an **Eloquent model**. Eloquent constructs one empty and fills it by attribute — which is
+exactly what `update()` has always done to a row that exists. Create was refusing on a risk update was already
+taking, and the inconsistency cost every application a CRUD surface that stopped at RUD.
+
+So: `create()` is offered when the resource is Eloquent-backed, under the same two switches, the same coercion and
+the same unknown-field refusal. The identifier and any masked column are **omitted from the form** rather than
+disabled in it — a field the browser would refuse to write should not appear to accept — so a crafted POST cannot
+choose a primary key or plant a value the page would only ever show as `******`.
+
+## Reads: four paths, and one of them is a foot-gun
+
+| # | Path | How |
+|---|---|---|
+| 1 | **Paged, unsearched** | `findPaged(Pageable)` — the repository does the offset, limit, `ORDER BY` and `COUNT`. The only path whose cost is independent of table size. |
+| 2 | **Paged, searched, Eloquent** | `findBySpecificationPaged(Specification, Pageable)` — filter, page and count all happen in SQL |
+| 3 | **Unpaged** | `findAll()`, then sort and slice **in PHP** |
+| 4 | **Unpaged, searched** | Path 3 plus an in-PHP substring filter; no SQL is involved in the matching at all |
+
+Path 2 goes through `EloquentRepository`'s public **specification seam**, which applies the predicate to the
+repository's *own* `query()` builder. Going around it with `Model::query()` would have been shorter and would
+have silently dropped any constraint a repository added by overriding `query()` — which on a repository that
+scopes to a tenant is a cross-tenant disclosure.
+
+!!! warning "Path 3 is a foot-gun, and it is load-bearing to say so"
+ `findAll()` on a plain `CrudRepository` issues `SELECT *` with no `LIMIT`, hydrates every row of the table
+ into PHP objects, and only then does the browser throw away all but 25 of them. On a table of ten thousand
+ rows that is a slow page; on a table of ten million it is an out-of-memory that kills the worker — and it
+ happens on the **first click**, not gradually. There is no way to do better through the `CrudRepository`
+ interface: it has no limit, no offset and no count-with-predicate. The honest options were "refuse to browse
+ repositories that cannot page" or "browse them and say what it costs"; this is the second. **A repository
+ that will be browsed against a large table should implement `PagingAndSortingRepository`**, at which point
+ it takes path 1.
+
+**Every listing is ordered, even when nobody asked.** With no `ORDER BY`, a paged query's row order is whatever
+the storage engine finds convenient, and it is allowed to differ between the query for page 1 and the query for
+page 2 — so a row can appear on both pages while another appears on neither, and the operator sees a table
+missing records that are actually there. With no requested sort, the identifier is used: stable, and always
+indexed.
+
+### Search is bound, never interpolated
+
+On the SQL paths the term is passed as a **binding** to `where(column, 'like', ?)`. It is never concatenated
+into a fragment, never handed to `whereRaw`, and therefore cannot become SQL no matter what it contains.
+
+The **column names are not caller data at all**: they come from the derived schema, built from the driver's own
+column list or from a class's declared properties, and a caller-supplied sort column is checked for membership
+in that list before use — an unknown one is dropped, not quoted.
+
+`%` and `_` inside the term are deliberately left as **wildcards** rather than escaped. `LIKE` has no portable
+escape character (sqlite has none by default, MySQL uses backslash, ANSI needs an explicit `ESCAPE` clause), so
+escaping "portably" means breaking search on some driver — and an operator who types `%` into an admin search
+box wants a wildcard.
+
+Search covers the **first twelve searchable columns** in schema order: OR-ing a `LIKE` across every text column
+of a wide table produces a query no index can help with, and past a dozen columns the page is slow enough that
+an operator will assume it hung.
+
+## Columns are a property of the resource, never of a row
+
+The tempting implementation is `$model->getAttributes()` on the first row, using its keys as the columns. It is
+wrong in three ways that all bite in production: an **empty table** yields no columns at all (so the page
+renders as broken rather than as empty), a row hydrated with a `select` of two columns yields two columns for
+the whole resource, and an accessor-heavy model yields whatever `$appends` decided rather than what the table
+holds.
+
+So columns are derived **once**, from a source that describes the resource:
+
+| `source` | Derivation | Meaning |
+|---|---|---|
+| `schema` | The live database via the schema builder | Authoritative — every column, real nullability |
+| `entity` | The entity class's public and promoted properties | Whatever the class chose to expose |
+| `none` | Nothing could be derived | No connection, no model, no typed entity |
+
+The source is recorded on the schema and shown, because *"why is this column missing"* is a question the page
+has to be able to answer.
+
+**The schema is authoritative; the casts refine it.** `Schema::getColumns()` reports what the driver knows, and
+the driver frequently does not know what the application meant — sqlite stores a `json()` column as `text` and a
+`boolean()` as `tinyint`, so a type map built from `type_name` alone shows a JSON blob as a string and a flag as
+a number. The model's own `$casts` carry the semantic type the schema cannot express, and where the two disagree
+**the cast wins**, because the cast is what the application will hand the view.
+
+### The display type vocabulary is closed, and deliberately small
+
+`string`, `int`, `float`, `bool`, `datetime`, `json`. It is a **rendering hint, not a schema echo**: the view has
+to decide "right-align this", "draw a chip", "format this as a timestamp", "clip this blob", and there are only
+those decisions plus a default. Anything outside the vocabulary degrades to `string` rather than reaching the view.
+
+!!! note "`float` never reformats the value"
+ A `decimal(10,2)` column arrives from PDO as the string `"10.10"`, and that is not an accident of the driver
+ — it is how the value survives a round trip without binary floating point eating the last cent. Every
+ non-integer number used to be typed `string` for that reason, which meant a money column read as a string in
+ the explorer, was offered to a `LIKE` search, and let the editor save `"abc"` into it.
+
+ `float` is a hint about **alignment and validation**, not about formatting. The cell prints the value
+ verbatim, so `"10.10"` renders as `10.10`; it is right-aligned with tabular numerals so digits line up down
+ the column. And a write is **validated** with `is_numeric` but **stored as the string**: casting to a PHP
+ float to store it would reintroduce exactly the precision loss a `decimal` column exists to avoid —
+ `12345678901234567890.12` does not survive a `float`, and the driver can bind the digits verbatim.
+
+### The identifier is derived, and allowed to be null
+
+Everything past the listing keys on it: the detail view addresses a row by it, delete addresses a row by it, and
+update addresses a row by it *while refusing to write it*. A browser that guessed wrong would render a link to a
+row it cannot fetch — or, far worse, issue a delete whose `WHERE` clause matched more than one row.
+
+So it is derived explicitly: Eloquent's own `getKeyName()` (which respects a model that renamed it), or a
+conventional identifier property on a plain entity. When it cannot be determined the resource is browsable as a
+**list and nothing else**, and every `find`/`delete`/`update` is refused with a reason rather than improvised.
+
+Records are projected against the schema's column list and inherit **its order**, with a column the row did not
+supply present as `null` rather than missing. `getAttributes()` returns keys in whatever order the driver
+returned them, which differs between drivers and can differ between two rows of the same table after a migration
+adds a column — and a detail page whose fields move between rows is unreadable.
+
+## Relations
+
+An entity's relations are discovered by **calling** the methods that declare one, because that is the only way to
+learn which columns they join on: a method's name says nothing and its return type says only the kind.
+
+Which makes "what is safe to call" the load-bearing question, and the answer is the **declared return type**. Only
+a public, non-static, no-argument method whose return type is an Eloquent `Relation` subclass is ever called — a
+method announcing `: HasMany` is a relation definition by construction, the same signal Laravel's own IDE tooling
+and `with()` validation rely on, and one an accessor cannot claim without lying about its signature. Anything
+without that annotation is left alone. The call itself executes no query: Eloquent defers until `get()`.
+
+```php
+class OrderEntity extends Model
+{
+ /** @return HasMany */
+ public function lines(): HasMany
+ {
+ return $this->hasMany(OrderLineEntity::class, 'order_id');
+ }
+}
+```
+
+| Relation | On the record page | Where it goes |
+|---|---|---|
+| `BelongsTo` | **Open →** | the one parent record |
+| `HasOne`/`HasMany` | **Browse →** | the child listing, filtered to this row's key |
+| `BelongsToMany`, `HasManyThrough`, the morph family | listed, not linked | no single column to filter on |
+| `MorphTo` | listed, not linked | the other end is decided per row by a type column |
+
+A relation whose other end is **not a browsable resource** — no repository declares it, or its resource is excluded
+— is still shown, because it tells a reader the shape of the model, but is not rendered as a link: distinguishing
+the two in the model rather than in the template is what stops a view minting a URL that 404s.
+
+## Filtering, sorting and paging
+
+The listing is a **URL**. Every filtered, sorted, paged view is something an operator can bookmark, paste into a
+ticket or hand to someone else, which is most of what a data explorer is for — and every link on the page carries
+the whole state, because a sort that dropped the filter would widen the listing back to every row, which reads as
+rows appearing from nowhere.
+
+Eight comparisons, over the columns the resource publishes **minus the masked ones**:
+
+| Operator | Meaning |
+|---|---|
+| `eq`, `ne` | `=`, `!=` |
+| `contains`, `starts` | `LIKE`, with the wildcards added around an **escaped** value |
+| `gt`, `lt` | `>`, `<` |
+| `null`, `notnull` | `IS NULL`, `IS NOT NULL` |
+
+Two spellings in the URL: `?fk=order_id&fv=7` is a single equality and is what every relation link produces —
+short enough to read in a status bar — while `?fc[]=…&fo[]=…&fv[]=…` is what the filter bar builds. Both are
+validated identically.
+
+**A column the schema does not publish for filtering, and an operator outside that set, are dropped** rather
+than passed to the driver. Both arrive in a URL an operator can hand-edit, and a query that reached the driver
+with an arbitrary identifier in it is a column-name oracle at best. Dropping rather than erroring is deliberate
+too: an error that distinguished "no such column" from "no rows" would answer the same question more slowly.
+
+!!! danger "A sensitive column is not filterable, and this was learned the hard way"
+ Filtering was first shipped over *every* column, which quietly re-opened the channel masking exists to
+ close. A masked column renders as `******`, but a filter over it answers a yes/no question about the real
+ value — and a yes/no question you can ask repeatedly is an **extraction oracle**. An adversarial review of
+ the branch proved it against the fixture: twenty-one filtered requests recovered `correct horse battery`
+ from a column the listing showed only as asterisks, and `>`/`<` do it faster still by binary search. The
+ filterable set is now `searchable()`'s rule applied to every type — everything except the masked columns —
+ and `tests/Data/DataFilterSafetyTest.php` runs the original attack as a regression test.
+
+!!! danger "Escaping a `LIKE` without an `ESCAPE` clause is worse than not escaping"
+ The same review found the other half. `contains` and `starts with` backslash-escape the user's `%` and
+ `_` so they cannot act as wildcards — but a plain `LIKE ?` leaves the driver with no escape character
+ declared, so the backslash is matched *literally*. Suppressing the wildcards worked; finding anything
+ containing an underscore stopped working, and it failed **silently**: a search for `ada_love` returned
+ zero rows against a table holding `ada_lovelace@example.test`. The predicate now emits an explicit
+ `ESCAPE` clause, with the column wrapped by the grammar rather than interpolated, and the search box —
+ which had no escaping at all, so a bare `%` matched every row — goes through the same helper.
+
+Filters **AND** with each other and with the search box, so narrowing a relation's listing cannot escape it. Every
+comparison binds its value, including the `LIKE` ones.
+
+The same eight comparisons are implemented for the [in-PHP fallback path](#reads-four-paths-and-one-of-them-is-a-foot-gun),
+because a repository that cannot page must be filtered by the same rules as one that can — two implementations
+would drift, and the drift would show as one filter meaning different things on different resources.
+
+## Secrets
+
+Sensitivity is decided **by name, in one place**: the actuator's own `SensitiveValueMasker`, the same rule that
+masks `/env` and `/configprops`, reused rather than mirrored — a second copy of a masking list is how a masking
+list rots.
+
+A model's own **`$hidden` is treated as a second sensitivity source.** The name rule catches `password`,
+`api_token` and their relatives but cannot know that this application considers `recovery_phrase` a secret. A
+model that already hid a field from its JSON representation has stated that intent in the only place it could,
+so the browser honours it rather than publishing in HTML what the model refuses to publish in JSON.
+
+A sensitive column is masked in the listing, masked in the detail view, **excluded from search**, and **refused
+as an update target**:
+
+- Excluded from search because a box that answers *"yes, some row's `api_token` starts with `sk_live_9`"* is an
+ oracle, and an operator can walk it one character at a time.
+- Refused as an update target because its *displayed* value is `******` — round-tripping a rendered form would
+ write the mask over the real credential, which is a data-loss bug the masking itself created.
+
+The **identifier** is refused as an update target too, for a different reason: re-keying a row from a generic
+form is not an edit, it is a different row. Foreign keys pointing at the old value do not follow, and the browser
+has no way to know which ones exist.
+
+Both refusals are enforced twice — a predicate the view uses to render the field read-only, and again in the
+write path, so a hand-crafted POST cannot reach what the form would not offer.
+
+## Writes
+
+`update()` and `delete()`, both returning a typed `DataWriteResult` with **four distinguishable outcomes**
+rather than a bool:
+
+| Outcome | Means | What the operator should do |
+|---|---|---|
+| `Done` | It happened | See the new state |
+| `Refused` | A gate, or something the browser will never do | Change configuration — or stop asking |
+| `NotFound` | The row or the resource is gone | Navigate away; a retry will not help |
+| `Failed` | The database said no | Look at the log |
+
+A bare `false` collapses four situations a person needs to tell apart, and rendering "delete failed" for all four
+sends an operator to debug a database that is working perfectly because a config key is off.
+
+**An update only writes what actually changed.** A submitted form round-trips every field; the ones whose value
+did not change — plus the identifier and any masked secret — are dropped, and the result lists the columns
+actually written. A submitted field that is not a column of the resource refuses the whole update rather than
+being ignored.
+
+**A delete is verified after the fact** with `existsById()` rather than trusted, because
+`CrudRepository::deleteById()` returns `void`: a repository whose delete was a no-op — a soft-delete scope that
+excluded the row, an override that swallowed it — would otherwise report success, and the operator would watch
+the row reappear on the next page load.
+
+**A non-Eloquent resource is refused for writes**, with a reason. There is no table to address and no
+`setAttribute` to call.
+
+## Nothing throws at the caller, and no error text is an exception message
+
+Reads answer with a listing that carries a reason, or a null record; writes answer with one of the four outcomes.
+A view rendering an admin page must not have to be exception-safe to stay on its feet — and, more sharply, an
+exception that escaped would be rendered by the framework's error page.
+
+That matters because of what a database exception *contains*. Laravel's `QueryException` stringifies the failing
+SQL **and its bindings** into `getMessage()`. Echoing that to the browser would publish the schema and, far
+worse, the values that were bound — which on a search over a users table is the operator's own query, and on a
+detail lookup is a primary key.
+
+So every reason a page can render is a **fixed sentence composed in this layer**, plus at most the exception's
+class name. The message stays in the exception, where a log can have it.
+
+## Configuration (`firefly.admin.data.*`)
+
+| Key | Default | Meaning |
+|---|---|---|
+| `firefly.admin.data.enabled` | **`false`** | Enable the browser at all — the `/firefly/data` pages, the entity map, and the `DataBrowser` API. Does **not** follow `app.debug` or `firefly.admin.enabled` — see [The two gates](#the-two-gates). |
+| `firefly.admin.data.writable` | **`false`** | Allow `update` and `delete`. Requires `enabled` as well; ineffective alone. |
+| `firefly.admin.data.page-size` | `25` | Default rows per page. Clamped into `[1, max-page-size]`. |
+| `firefly.admin.data.max-page-size` | `200` | Ceiling applied to any caller-supplied page size. Itself capped at **1000**, because `?perPage=1000000` on a resource that cannot page is a request to materialise the table into PHP memory. |
+| `firefly.admin.data.exclude` | `''` | CSV of resource slugs to refuse. A **hard refusal, not a menu preference**: the resource is hidden *and* every operation on it is refused. Hiding `user` because the table holds PII achieves nothing if the row URL still answers. |
+| `firefly.admin.data.relations` | `true` | Discover relations, so records link to what they reference and the [entity map](admin.md#the-entity-map) has edges. Discovery **calls** the model methods that declare one — see [Relations](#relations) — so it is a key rather than a constant. |
+
+The page-size cap is applied to whatever the caller asks for, so the query layer never sees a size it did not
+agree to.
+
+## Reflection is confined to one class
+
+Discovery reads the compiled catalogue; schema derivation reads Laravel's schema builder; queries read the
+container. Exactly one class reflects, and only for two facts no manifest carries:
+
+1. **Which model a repository manages.** `EloquentRepository` declares `protected string $model` and the
+ concrete repository sets it as a property *default*. It is protected, there is no accessor, and the value
+ never reaches a descriptor — `ComponentScanner` records a class's dependencies and interfaces, not its
+ property initialisers. It is read via `getDefaultProperties()`, which does **not** construct the repository:
+ discovery must stay cheap and must not be able to fail because a repository constructor wanted a live
+ connection.
+2. **What shape a non-Eloquent entity has.** A plain `CrudRepository` over value objects has no table to ask, so
+ the only honest column list is the entity's declared fields — public properties and promoted constructor
+ parameters. Promoted parameters are why accessibility has to be bypassed: `Firefly\Domain\Entity` promotes
+ `protected int|string|null $id`, so a public-only scan would miss the identifier of every entity built on the
+ framework's own DDD base class.
+
+Confining both to one class is what keeps the rest honest — the registry, the schema factory, the query engine
+and the browser contain no `Reflection*` reference at all, so the cost and the risk are auditable by grep. Every
+entry point is guarded and memoised: reflection on a class the autoloader cannot complete throws, and a
+resource list that dies because one repository is broken is useless, so a failure degrades **that one resource**
+instead.
+
+## Known-latent
+
+- **No create for a non-Eloquent repository**, permanently — see
+ [above](#create-exists-for-eloquent-and-is-refused-for-everything-else).
+- **Writes are Eloquent-only.** A plain `CrudRepository` over value objects is browsable and read-only, for
+ the same reason.
+- **A pivot or a polymorphic relation is listed, not walkable.** `BelongsToMany`, `HasManyThrough` and the
+ morph family have no single column the browser can filter on — a `MorphTo`'s other end is decided per row
+ by a type column — so they appear on a record page and are absent from the [entity map](admin.md#the-entity-map),
+ because a line with no join to name would be decoration.
+- **`firefly/admin` still ships no authentication of its own.** The data browser inherits the dashboard's
+ access model exactly, which means the [route-level protection](admin.md#access-the-whole-security-boundary)
+ is your responsibility — and matters more here than anywhere else in the dashboard.
+
+---
+
+See also: [Admin Dashboard](admin.md) for the access model this page sits inside, [Data & Repositories](data.md)
+for `CrudRepository`/`PagingAndSortingRepository` and the specification seam, and
+[Relational Data](data-relational.md) for `EloquentRepository`.
diff --git a/docs/modules/data.md b/docs/modules/data.md
index 21ff1d3..d4426c2 100644
--- a/docs/modules/data.md
+++ b/docs/modules/data.md
@@ -252,3 +252,17 @@ enum Direction: string
`Direction`'s backing value **is** the Eloquent `orderBy()` direction string, so the mapping at the Eloquent
edge is a bare `->orderBy($order->property, $order->direction->value)` with no translation table.
+
+---
+
+## Browsing what a repository holds
+
+`firefly/admin` ships a [data browser](data-browser.md) that discovers its resources from exactly these ports:
+any bean whose scan-time interface list contains `CrudRepository` is browsable, and a repository that also
+implements `PagingAndSortingRepository` is paged **in the database** rather than in PHP — which on a large table
+is the difference between one page of rows and an out-of-memory.
+
+It is **disabled by default** and does not follow `app.debug` or `firefly.admin.enabled`; writes need a second
+key on top of that, and create is offered only for an Eloquent-backed resource — for a hand-written aggregate
+the invariants live in its constructor, not in a column list. See [Data Browser](data-browser.md) for the
+reasoning, and for the relations it walks between your entities.
diff --git a/docs/modules/dependency-injection.md b/docs/modules/dependency-injection.md
index ce346bc..770949d 100644
--- a/docs/modules/dependency-injection.md
+++ b/docs/modules/dependency-injection.md
@@ -55,6 +55,25 @@ $container->getByName('spanish'); // SpanishGreeter
$container->getAll(Greeter::class); // all implementations, sorted by #[Order]
```
+`#[Qualifier]` also works **on an injected parameter**, which is how you ask for a specific bean without
+going through `getByName()`:
+
+```php
+final class Notifier
+{
+ public function __construct(
+ #[Qualifier('spanish')] private readonly Greeter $greeter,
+ ) {}
+}
+```
+
+It rides `Illuminate\Contracts\Container\ContextualAttribute`, the same seam `#[Value]` uses, so it adds
+no reflection that was not already happening and leaves the compiled manifest shape untouched. It works on
+`#[Bean]` factory-method parameters as well as constructors. A name that is not registered throws a
+`ConfigurationException` naming the qualifier — it does not fall back to the type. (Parameter qualifiers were
+declared but read by nothing until recently: `#[Qualifier('redisCache')] Cache $cache` silently received
+whatever `Cache::class` resolved to.)
+
## `#[Order]`
`#[Order]` sets list precedence (lower first, Spring convention). `getAll()` returns implementations sorted by it.
@@ -67,8 +86,7 @@ already resolves bindings lazily by default, so there is nothing extra to defer
## `#[Bean]` factory methods
-A `#[Configuration]` class exposes `#[Bean]` methods; each is registered under its return type, with parameters
-injected:
+A component exposes `#[Bean]` methods; each is registered under its return type, with parameters injected:
```php
use Firefly\Container\Attributes\{Bean, Configuration};
@@ -81,6 +99,56 @@ final class AppConfig
}
```
+`#[Bean]` methods are collected from **any** component — `#[Configuration]`, a user-defined stereotype that
+extends it, and plain `#[Component]`/`#[Service]`/`#[Repository]` classes (Spring's "lite mode"). Discovery
+uses the same `IS_INSTANCEOF` rule as every other stereotype check; it does not compare attribute short
+names, which used to make `#[Bean]` methods on an `ApiConfiguration extends Configuration` disappear from the
+manifest while the class itself was still bound.
+
+### Two or more `#[Bean]` methods returning the same type
+
+!!! warning "Breaking change"
+ Two non-`#[Primary]` `#[Bean]` methods returning the same type now **throw at registration**. They
+ previously booted, and one of the two beans silently did not exist.
+
+Per return type:
+
+- **One bean produces the type** (the common case): unchanged. The factory is bound on the return type, and
+ the name, if any, is aliased to it — the type and the name resolve to the same singleton.
+- **Several beans produce the type**: each is bound under its **own name key**, so every one is individually
+ resolvable, and the bare type key becomes an **alias** of the `#[Primary]` winner. An alias, never a second
+ binding — a second binding of the same factory would quietly mint a second "singleton".
+- **Several beans, no `#[Primary]`**: the type key is bound to a guard that throws a `ConfigurationException`
+ naming every candidate. The type stays *bound*, so `#[ConditionalOnMissingBean]` still sees that a bean of
+ that type exists; leaving it unbound would let a concrete return type silently auto-wire past every
+ `#[Bean]` factory.
+
+Four shapes are rejected at registration time, where the stack trace still points at the manifest rather
+than at some unlucky consumer:
+
+| Rejected | Why |
+|---|---|
+| Competing beans where one or more is **anonymous** | An unnamed bean is reachable only through its return type, which its competitors already claim — it could never be resolved. |
+| Competing beans **sharing one name** | A bean name is a container key; the second would silently overwrite the first. |
+| Competing beans where one is **named after the type itself** | That name *is* the group's type key, claimed by the `#[Primary]` winner or the guard. |
+| **More than one `#[Primary]`** for a type | `#[Primary]` names the single default; at most one candidate may carry it. |
+
+What this replaces: names were previously only ever recorded as `alias($returns, $name)`, and an alias is a
+pointer to a key rather than a binding of its own. Two `#[Bean]` methods returning the same type therefore
+collapsed — both names pointed at the one type key, which held whichever factory registered last, so
+`getByName('memoryCache')` and `getByName('redisCache')` handed back the identical object. `#[Primary]` could
+not break the tie because it was read nowhere in the bean path at all.
+
+**To migrate**, give each competing method a distinct name and mark one primary:
+
+```php
+#[Bean('memoryCache')] #[Primary]
+public function memoryCache(): Cache { /* … */ }
+
+#[Bean('redisCache')]
+public function redisCache(): Cache { /* … */ }
+```
+
## `#[Value]` injection
Inject configuration and expressions into constructor parameters:
diff --git a/docs/modules/eda-brokers.md b/docs/modules/eda-brokers.md
index 03921d0..8f440dc 100644
--- a/docs/modules/eda-brokers.md
+++ b/docs/modules/eda-brokers.md
@@ -61,7 +61,7 @@ provider selected the whole package resolves nothing and requires nothing.
| `firefly.eda.postgres.connection` | the default DB connection | The named Laravel connection the outbox publisher/consumer/relay use — must be `pgsql` for `pg_notify`/`LISTEN` to activate (any other driver, e.g. `sqlite` in tests, silently skips the NOTIFY optimization and falls back to polling). |
| `firefly.eda.postgres.channel` | `firefly_eda_events` | The `LISTEN`/`NOTIFY` channel name and the outbox row's `channel` column value. |
| `firefly.eda.postgres.max_attempts` | `3` | How many `nack()`s (in-process consumer) or failed relay attempts an outbox row tolerates before it is marked `FAILED`. |
-| `firefly.eda.postgres.relay.downstream_provider` | *(unset)* | **OPTIONAL.** `rabbitmq`\|`kafka` — when set, `firefly:outbox:relay` forwards `PENDING` rows to that distinct downstream broker. When unset, the relay command is a documented no-op and delivery is entirely the terminal in-process consumer's job. |
+| `firefly.eda.postgres.relay.downstream_provider` | *(unset)* | **OPTIONAL.** Selects the relay's downstream: the shipped aliases `rabbitmq`\|`kafka`, the class-string of any `EventPublisher`, or a bound container id. When set, `firefly:outbox:relay` forwards `PENDING` rows there; when unset the command refuses to run with an error naming this key, and delivery is entirely the terminal in-process consumer's job. An app that binds its own publisher under the container id `firefly.eda.relay.downstream` may leave this key unset — that binding is checked first. Resolution is validated when the relay command runs (not at boot), so a misconfiguration fails before a single row is claimed instead of at the first publish. Whatever it resolves to, a `PostgresEventPublisher` is **refused**: it would re-insert `PENDING` rows into the same outbox. |
### Kafka (`firefly/eda-kafka`)
@@ -181,10 +181,21 @@ Kafka — just the natural insertion order of one table).
### (b) The optional relay — `firefly:outbox:relay`
-`firefly:outbox:relay` is a **distinct, optional** path that fronts a **different downstream broker** —
-set `firefly.eda.postgres.relay.downstream_provider=rabbitmq|kafka` to enable it. When that key is unset,
-running the command is a documented no-op (it logs and exits `SUCCESS` immediately): terminal delivery
-via `firefly:eda:consume` is assumed instead.
+`firefly:outbox:relay` is a **distinct, optional** path that fronts a **different downstream broker**. Enable
+it by setting `firefly.eda.postgres.relay.downstream_provider` — to a shipped alias (`rabbitmq`/`kafka`), to
+an `EventPublisher` class-string, or to the id of a binding you supply — or by binding your own publisher
+under the container id `firefly.eda.relay.downstream` (checked first, so the key may then stay unset).
+
+With neither configured, running the command **fails** with a console error naming the key and the available
+aliases, and exits `FAILURE`. That is deliberate: an operator who starts the relay expects rows to move, and a
+silent success would look like a working relay that delivers nothing. If you did not mean to front a second
+broker, simply do not run the command — `provider=postgres` already delivers committed rows in-process via
+`firefly:eda:consume`.
+
+The downstream is resolved **when the command runs**, not at boot, and a bad value is reported before a single
+row is claimed. Boot-time validation was considered and rejected: a downstream bound in another provider's
+`boot()` may not exist yet when the check would run, so it would fail correctly-configured applications — and
+it would fail every web request and every unrelated artisan command over a relay-only concern.
`OutboxRelay::relayBatch()` claims a batch of `PENDING` rows (`FOR UPDATE SKIP LOCKED` on pgsql, inside a
short transaction so concurrent relay workers never double-claim; a plain per-row `WHERE id=? AND
@@ -194,7 +205,8 @@ status='PENDING'` guard on drivers without row locking), publishes each through
Both the command and `OutboxRelay`'s constructor **refuse a `PostgresEventPublisher` as the downstream** —
that would re-INSERT `PENDING` rows into the very same outbox, an infinite loop — throwing a `LogicException`
-/ printing a clear error instead. The relay is for genuinely bridging to a *different* broker (e.g. you want
+/ printing a clear error instead. The refusal applies however the downstream was named: alias, class-string
+or container binding. The relay is for genuinely bridging to a *different* broker (e.g. you want
Kafka as your public-facing bus but still want the same-tx outbox guarantee for the write); it is not an
alternative in-process delivery mechanism.
diff --git a/docs/modules/eda.md b/docs/modules/eda.md
index e5a5cbe..740b2cf 100644
--- a/docs/modules/eda.md
+++ b/docs/modules/eda.md
@@ -70,7 +70,9 @@ encodes.
### `InMemoryEventBus` — the default
-A `SubscriberRegistry` (pattern → handler pairs) delivered to in subscription order via `fnmatch()`.
+A `SubscriberRegistry` (pattern → handler pairs) delivered to in subscription order via `fnmatch()` — and
+subscription order is the manifest's `order` order, because `EventListenerWiringPass` subscribes listeners
+sorted by `#[EventListener(order:)]`.
`publish()` builds the envelope and calls `deliver()` **synchronously** — every matching handler runs before
`publish()` returns. `start()`/`stop()` are no-ops. Zero external services; this is the skeleton default
(`firefly.eda.provider` unset or `memory`).
@@ -124,10 +126,14 @@ several patterns (or several separately-ordered annotations) at once.
- **`EventListenerWiringPass`** runs at `BootPhase::WiringPasses` (1000), in *every* process — web request
and queue worker alike. For each manifest row it wraps the target invocation in `RetryingEventHandler`
(config-driven retries/delay + the bound `DeadLetterStore`) and calls `$bus->subscribe($pattern, $wrapped)`
- for each of the descriptor's patterns. The invoking closure resolves the target bean **fresh from the
- container on every dispatch** — never cached at registration — so it always observes the fully
- post-processed (possibly proxied) bean, exactly like `RegisterEventListenersPass` does for the in-process
- surface.
+ for each of the descriptor's patterns. It iterates `EventListenerManifest::ordered()` — descriptors sorted
+ by their declared `order` **ascending** (lower first, the `#[Order]` convention used throughout the
+ framework), ties keeping compiled-manifest order — so `#[EventListener(order:)]` genuinely determines
+ dispatch order. (It used to iterate `all()`, so the parameter round-tripped through the manifest and was
+ then discarded: ordering was whatever the scanner happened to emit.) The invoking closure resolves the
+ target bean **fresh from the container on every dispatch** — never cached at registration — so it always
+ observes the fully post-processed (possibly proxied) bean, exactly like `RegisterEventListenersPass` does
+ for the in-process surface.
## Retry / DLQ model
@@ -186,18 +192,20 @@ bytes on a real wire.
| Key | Type | Default | Meaning |
|---|---|---|---|
-| `firefly.eda.provider` | `memory`\|`queue` | `memory` | Selects `InMemoryEventBus` or `QueueEventBus` (`EdaAutoConfiguration`). |
+| `firefly.eda.provider` | `memory`\|`queue`\|`rabbitmq`\|`postgres`\|`kafka` | `memory` | `EdaAutoConfiguration` selects `InMemoryEventBus` or `QueueEventBus`; the three broker values are honoured by the adapter packages (see [EDA brokers](eda-brokers.md)) and read here as "not queue". |
| `firefly.eda.serialization_format` | string | `json` | Selects `Serializer`; anything but `json` throws `SerializationException`. |
| `firefly.eda.retries` | int | `0` | Handler-level retry count applied to every `#[EventListener]` by `EventListenerWiringPass`. |
| `firefly.eda.retry_delay` | float (seconds) | `0.0` | Linear-backoff base delay (`retry_delay * attempt`). |
| `firefly.eda.queue.connection` | string\|null | `null` (default connection) | Queue connection `QueueEventBus`/`DispatchEventJob` dispatch onto, when `provider=queue`. |
| `firefly.eda.queue.name` | string\|null | `null` (default queue) | Queue name, when `provider=queue`. |
+| `firefly.eda.destinations` | list\ | `[]` | The broker destinations `php artisan firefly:eda:consume` binds when `--destination` is not passed. Read **only** by that command; it must be a list of strings or the command throws a `ConfigurationException`. |
-!!! note "`destination` is a call-site argument, not a config key"
+!!! note "A publish `destination` is a call-site argument, not a config key"
`EventPublisher::publish(string $destination, ...)` takes the destination explicitly at the call site —
- it is not read from configuration. The test suite and capstones use `'firefly.events'` by convention as
- an app-facing default destination name, but no shipped code binds or reads a
- `firefly.eda.destinations` config key; nothing in `EdaAutoConfiguration` or the wiring pass consults one.
+ it is never read from configuration, and neither `EdaAutoConfiguration` nor the wiring pass consults a
+ key for it. `firefly.eda.destinations` above is the *consumer* side: which destinations to subscribe to,
+ used by the consume command alone. `--destination` on the command line overrides it, so one compiled
+ config can still serve several workers.
Pick whatever destination string suits your application.
`EdaAutoConfiguration` (`#[Configuration] #[Order(1000)]`) binds the `EventPublisher`, `Serializer`, and
@@ -253,11 +261,10 @@ These are carried-forward, documented limitations of the M9 shipment — not bug
- **"Async" is queue-backed, not coroutine-based.** Under the `sync` queue driver (or with no worker
running), `QueueEventBus` delivers synchronously, identically to `InMemoryEventBus`. Genuine asynchronous,
cross-process delivery requires a running queue worker (`queue:work`, Horizon, …).
-- **App-level `#[EventListener]` manifests compile via `firefly:cache` — landing in M15.** Until then, an
- application supplies its compiled `EventListenerManifest` inline (bind it directly, or hand-run
- `EventListenerScanner` + the manifest compiler) rather than through an automated cache-warm command; a
- listener method absent from the compiled manifest silently never subscribes — the same compile-inline
- caveat as web routes and scheduled tasks.
+- **A listener outside `firefly.scan.paths` never subscribes.** The `EventListenerManifest` resolves to the
+ `firefly:cache` artifact if present, otherwise an in-process scan of `firefly.scan.paths`, otherwise empty
+ — so no hand-wiring is needed, but a listener the scan cannot see is silently absent rather than an error.
+ Run `firefly:cache` in production for the reflection-free path.
- **`firefly/eda` and `firefly/messaging` are independent sibling packages** — see
[Messaging § Sibling of `firefly/eda`](messaging.md#sibling-of-fireflyeda-no-shared-code) for why there is
no dependency in either direction.
diff --git a/docs/modules/error-handling.md b/docs/modules/error-handling.md
index d7cfab6..6aa58bf 100644
--- a/docs/modules/error-handling.md
+++ b/docs/modules/error-handling.md
@@ -80,9 +80,27 @@ $payload = $response->toArray(); // omits null/empty optionals
`FireflyException` thrown while handling a request is turned into an `application/problem+json` response
by `Firefly\Web\Exception\ProblemDetailsRenderer`, via `ErrorResponse::fromException(...)`, at the
exception's own `httpStatus()` — the shape is exactly the payload above, produced by the same
-kernel-level `ErrorResponse` this page documents. A generic (non-`FireflyException`) `Throwable` is
-first wrapped as a category-`Internal`, HTTP-500 `FireflyException` before being rendered the same way,
-whenever the request expects JSON (`$request->expectsJson()`).
+kernel-level `ErrorResponse` this page documents.
+
+`Firefly\Web\Error\ProblemMapper` owns the rule for turning *any* throwable into that shape, in one place,
+because the HTML page below needs the same answer and two copies of it would eventually tell a browser and a
+client different things about one failure. It has **three** cases, and only the third is a disclosure:
+
+| Throwable | Status | Whose message is it? |
+|---|---|---|
+| A `FireflyException` | its own `httpStatus()` | the application's, written **for** the client |
+| An `HttpExceptionInterface` (the router's own 404, `abort(409, '…')`) | its real status | the author's, via `abort()` |
+| Anything else | 500 `INTERNAL_ERROR` | **an accident**, and withheld — see below |
+
+!!! danger "A generic throwable's message is not for the client"
+ A `QueryException` stringifies the failing SQL *and its bindings*; a `TypeError` names an absolute path on
+ the server; a `PDOException` names the host it could not reach. All three were copied verbatim into
+ `detail` and published as problem+json — in production, with no `app.debug` gate anywhere on that path,
+ while the HTML page beside it withheld everything. Both renderings are now gated by the same switch,
+ `firefly.web.error-page.trace`, which follows `app.debug`: with it off an unhandled throwable answers
+ `An unexpected error occurred.` and its real message stays on the exception, where the log has it. When
+ no settings object is bound at all — a JSON-only deployment that never constructed one — the default is
+ the **safe** one; an absent gate must not mean an open one.
Before that generic rendering happens, LaraFly gives the application a chance to handle the exception
itself:
@@ -96,6 +114,68 @@ itself:
handler for a more-derived exception class outranks one for an ancestor class.
- A matched handler's return value is content-negotiated like any other controller return, but rendered
at the **exception's** `httpStatus()` rather than the route's default status.
-- If no handler matches at any scope, the exception propagates to the RFC-7807 renderer described above —
- so an unhandled 404/422/500 always still comes back as `application/problem+json`, never an uncaught
- framework error page.
+- If no handler matches at any scope, the exception propagates to the renderers described above — so an
+ unhandled 404/422/500 comes back as `application/problem+json`, or as the LaraFly error page when the
+ caller asked for HTML (see the next section), and never as an uncaught framework error page.
+
+## Who gets JSON, and who gets a page
+
+The same failure is rendered two ways, and the choice is not "is this a `FireflyException`". It used to be,
+which meant a person clicking a stale link to `/orders/999999` in a browser was shown a raw JSON blob: the
+exception taxonomy that makes LaraFly's errors consistent for clients was the very thing that made them
+unreadable for people.
+
+| The caller | What it gets |
+|---|---|
+| Named `text/html` (or `application/xhtml+xml`) in `Accept` | The HTML error page |
+| Asked for JSON, or is an `XMLHttpRequest` | `application/problem+json` |
+| Sent only a wildcard `Accept` — a bare `curl` | `application/problem+json` |
+| Requested a path under `firefly.web.error-page.json-paths` | `application/problem+json`, whatever it asked for |
+
+The rule is **the client NAMED text/html**, not `acceptsHtml()`. A bare `curl` sends `*/*`, which
+`acceptsHtml()` answers true for, so keying off it would have turned every unadorned command-line request
+against an API into an HTML page — a worse regression than the bug being fixed.
+
+`json-paths` is the stronger statement and is checked **first**: the Accept header says who is asking, the
+path says what the URL *is*. It defaults to `api/*`, because a developer opening an API URL in a browser
+wants the payload their client will receive, not a styled page telling them the endpoint renders HTML.
+
+## The HTML error page
+
+`firefly/web` ships a page in the same visual language as the welcome page and the admin dashboard, showing
+the status, the reason, the stable error `code` — the same one the problem document carries, so a support
+ticket quoting it finds the same code in the log — and, when permitted, the exception, its `previous` chain,
+the source around the throwing line, and the stack trace with **your** frames separated from your
+dependencies'.
+
+```php
+// config/firefly.php
+'web' => [
+ 'error-page' => [
+ 'enabled' => true, // false falls back to Laravel's own page
+ 'trace' => env('APP_DEBUG', false), // the disclosure gate; follows app.debug
+ 'title' => env('APP_NAME', 'LaraFly'),
+ 'excerpt-lines' => 7, // source lines around the throw, clamped 0-40
+ 'json-paths' => 'api/*',
+ 'views' => ['404' => 'errors.not-found', 'default' => 'errors.generic'],
+ ],
+],
+```
+
+**`trace` is enforced where the data is gathered, not where it is printed.** With it off the framework never
+walks the stack, never opens a source file and never copies the exception message — so there is nothing
+assembled for a template mistake to leak. Production shows the status, the reason and the code: enough to
+quote into a ticket and grep in a log, and nothing that names a class, a file or a row. The page's own
+advice about *how* to turn traces on is suppressed outside non-production environments too, because naming
+the framework and a config key to an anonymous visitor is a free hint about your stack.
+
+**Overriding it.** `views` hands a status — or `default` — to your own Blade view. The view receives the same
+`$error` report the built-in page gets, so it is bound by the same `trace` gate and cannot print a stack
+trace the settings withheld. A view that **throws** falls back to the built-in page rather than propagating:
+this renders while the application is already failing, and an override is application code (a renamed
+layout, a component querying the database that is down) — a white screen at that moment is the worst
+possible outcome.
+
+**It is not a Blade view itself.** The built-in page is assembled as a string with no container lookups, no
+view factory and no network font, because the failure being explained may *be* the view layer. String
+building is not the elegant choice; it is the one that still works when nothing else does.
diff --git a/docs/modules/messaging.md b/docs/modules/messaging.md
index 926f414..305257c 100644
--- a/docs/modules/messaging.md
+++ b/docs/modules/messaging.md
@@ -223,9 +223,9 @@ These are carried-forward, documented limitations of the M9 shipment — not bug
- **"Async" is queue-backed, not coroutine-based** — under the `sync` queue driver (or with no worker
running), `QueueMessageBroker` delivers synchronously (minus the group-drop above), same async→sync
contract as the rest of LaraFly.
-- **App-level `#[MessageListener]` manifests compile via `firefly:cache` — landing in M15.** Until then, an
- application supplies its compiled `MessageListenerManifest` inline rather than through an automated
- cache-warm command; a consumer method absent from the compiled manifest silently never subscribes — the
- same compile-inline caveat as `firefly/eda`'s listeners.
+- **A consumer outside `firefly.scan.paths` never subscribes.** The `MessageListenerManifest` resolves to
+ the `firefly:cache` artifact if present, otherwise an in-process scan of `firefly.scan.paths`, otherwise
+ empty — no hand-wiring needed, but a listener the scan cannot see is silently absent rather than an error.
+ Same shape as `firefly/eda`'s listeners.
- **`firefly/messaging` and `firefly/eda` are independent sibling packages** with no dependency in either
direction — see [Sibling of `firefly/eda`](#sibling-of-fireflyeda-no-shared-code) above.
diff --git a/docs/modules/observability.md b/docs/modules/observability.md
index 4fc5427..b02964e 100644
--- a/docs/modules/observability.md
+++ b/docs/modules/observability.md
@@ -14,13 +14,52 @@ flag, secure-by-default-on, zero boot reflection.
calls with the same identity return the same instance.
- `MetricsRecorder` — the narrow write-facing port instrumentation actually depends on (`increment()`, `record()`,
`setGauge()`), so callers never need the full registry.
-- `SimpleMeterRegistry` — the shipped in-memory implementation of **both** ports. `Counter` (monotonic), `Gauge`
- (pull-based, backed by a `callable(): float` supplier — sampled at *read* time, not write time), `Timer` (count +
- total-seconds, exposed as a Prometheus summary — **no** histogram buckets/percentiles yet).
+- `SimpleMeterRegistry` — the shipped in-memory implementation of **both** ports, and the default. `Counter`
+ (monotonic), `Gauge` (pull-based, backed by a `callable(): float` supplier — sampled at *read* time, not write
+ time), `Timer` (count + total-seconds, exposed as a Prometheus summary — **no** histogram buckets/percentiles yet).
+- `CacheMeterRegistry` — the cross-process implementation of both ports, bound instead when
+ `firefly.observability.metrics.store` names a cache store. See [Surviving the request](#surviving-the-request)
+ below.
- A metric **name** has exactly one type, globally: registering the same name under a different `MeterType` (e.g.
`counter('foo')` then `gauge('foo', ...)`) throws `InvalidArgumentException` — Prometheus scopes one `# TYPE` line
per name, so a silent type conflict would emit invalid exposition.
+## Surviving the request
+
+`SimpleMeterRegistry` keeps every meter in process memory. That is correct for a long-lived worker (Octane,
+RoadRunner) and wrong for PHP's usual deployment: under PHP-FPM each request is a fresh process, so by the time a
+scrape reaches `/actuator/metrics` or `/actuator/prometheus`, the only meters in memory are the ones that scrape's
+own request recorded. The endpoints were effectively empty in production — and the numbers they *did* show were a
+single request's, which is worse than empty, because it reads as data.
+
+Naming a cache store swaps in `CacheMeterRegistry`, which writes through to that store:
+
+- `increment()` and `record()` use the store's **atomic increment**, so concurrent workers cannot lose writes on a
+ driver that supports it (redis, memcached, apc, dynamodb). Durations accumulate in integer **microseconds**,
+ because `increment()` is integer-only and a float read-modify-write would drop samples under concurrency.
+- `setGauge()` is a plain `put()`: a gauge is a snapshot, so last-writer-wins is the correct semantic.
+- `meters()` rehydrates `Counter`/`Timer`/`Gauge` from a single index of every meter identity ever written, so it
+ costs one read rather than a key scan — which not every cache driver supports.
+- Tag order never splits a meter in two; identities sort their tags.
+
+```php
+// config/firefly.php
+'observability' => [
+ 'metrics' => [
+ 'store' => env('FIREFLY_METRICS_STORE', ''), // '' = in-process SimpleMeterRegistry
+ 'ttl' => 0, // seconds; 0 = no expiry
+ ],
+],
+```
+
+It is **opt-in** rather than the default on purpose: a metrics registry that silently starts writing to whatever
+cache an application happens to have configured is a surprise, and on the `array` driver it would be no better
+than memory anyway.
+
+The documented boundary: the factory methods (`counter()`/`timer()`/`gauge()`) still hand back the **in-process**
+meters, and mutating one of those directly stays process-local. Everything the framework itself records goes
+through the `MetricsRecorder` methods, which are the durable path.
+
## Exposition
- `/actuator/prometheus` (`PrometheusEndpoint`) — pure-PHP Prometheus text-exposition format 0.0.4. Names/labels are
@@ -94,11 +133,46 @@ flip the one flag, and the `MeterRegistry` bean, the CqrsMetrics winner, and eve
adapter can drop in at SP-7 with zero call-site changes — the same "port now, adapter later" shape as the CqrsMetrics
seam above.
+## HTTP exchanges and the process endpoint
+
+`/actuator/httpexchanges` serves the last N requests this application answered, newest first, and
+`/actuator/process` serves the live process numbers (pid, uptime, PHP version/SAPI, memory, OPcache) beside the
+request counter the same recorder already keeps. Neither is in the secure-by-default exposure list
+(`health,info`), so reaching either over HTTP means naming it in
+`firefly.management.endpoints.web.exposure.include`.
+
+Recording is done by `HttpExchangeFilter` — a `#[Component]` `WebFilter` discovered by web's
+`FilterChainRegistrar`, `#[Order(-100)]`, `#[Lazy]`, gated on its **own**
+`firefly.observability.httpexchanges.enabled` rather than on the metrics flag. Each row carries
+`timestamp`/`method`/`uri`/`status`/`durationMs`/`correlationId`; `uri` is the **route template** where a route
+matched, and otherwise the raw path with the query string dropped, capped at 256 characters. **No request or
+response body is ever retained, and there is no flag to enable one.** Headers are off by default; switching
+`include-headers` on adds a `requestHeaders` object whose credential-bearing entries (`authorization`, `cookie`,
+`proxy-authorization`, anything matching `password|secret|token|key|credential|passwd|authenticate`) are replaced
+with `******` by `HeaderMasker`.
+
+`HttpExchangeRecorder` is a port with the same two implementations, and the same reason for them, as
+`MeterRegistry`: `InMemoryHttpExchangeRecorder` (default, correct only on a long-lived worker) and
+`CacheHttpExchangeRecorder`. Under PHP-FPM the in-memory buffer is not merely stale but always **empty** — each
+request is a fresh process, and the request rendering the endpoint has not been recorded yet because the filter
+records on the way out. The payload therefore reports `storage` (`memory` or `cache:`), `processLocal`,
+`recording`, `capacity`, `recorded` (monotonic, so `recorded - count` is what the ring has evicted) and `count`,
+so an empty list can be told apart from a broken one. `?limit=N` trims the list; a malformed limit is ignored
+rather than answered with a `400`.
+
## Configuration (`firefly.observability.*`, kebab-case)
| Key | Default | Meaning |
|---|---|---|
| `firefly.observability.metrics.enabled` | `true` | Master gate. Binds `MeterRegistry`/`MetricsRecorder`/`PrometheusTextFormat`/the real `CqrsMetrics`, and survives on the endpoints + `MetricsFilter`. Disabled → `NoOpMetricsRecorder`, the M10 `NoOpCqrsMetrics` stays bound, no `MeterRegistry`, `/prometheus`+`/metrics` unmounted. |
+| `firefly.observability.metrics.store` | `''` | Names a **cache store**. Empty (or no `cache` binding) → the in-process `SimpleMeterRegistry`; a store name → `CacheMeterRegistry` over `cache()->store($name)`, keyed under `firefly:metrics:`. |
+| `firefly.observability.metrics.ttl` | `0` | Expiry in seconds for each cache-backed meter. `0` or less means no expiry. Only consulted when `store` is set. |
+| `firefly.observability.httpexchanges.enabled` | `true` | Gates `HttpExchangeFilter` — i.e. whether anything is recorded. Compared as the literal string `true` by `#[ConditionalOnProperty]`, so `1`/`on`/`yes` count as OFF. The endpoints stay mounted either way and report `"recording": false`. Independent of the metrics gate. |
+| `firefly.observability.httpexchanges.capacity` | `100` | Ring size, clamped to `[1, 10000]`. |
+| `firefly.observability.httpexchanges.store` | `''` | Names a **cache store**. Empty (or no `cache` binding) → the process-local `InMemoryHttpExchangeRecorder`; a store name → `CacheHttpExchangeRecorder` over `cache()->store($name)`, keyed under `firefly:httpexchanges:`, which is what makes the buffer non-empty under PHP-FPM. |
+| `firefly.observability.httpexchanges.ttl` | `0` | Expiry in seconds for each cache-backed row. `0` or less means no expiry. Only consulted when `store` is set. |
+| `firefly.observability.httpexchanges.include-headers` | `false` | Adds masked request headers to each row. Bodies are never recorded, with or without this. |
+| `firefly.observability.httpexchanges.exclude` | `[, /*]` | Glob patterns whose requests are not recorded. The default keeps a polling dashboard from evicting real traffic from its own ring; setting it **replaces** the default rather than adding to it. |
| `firefly.resilience.circuit-breaker.*` | _(unset)_ | Read by `MeterBindingsPass` (not owned by this package) — one named instance here gets one `resilience_circuit_breaker_state{name}` gauge. |
## Laravel comparison
@@ -117,8 +191,12 @@ seam above.
span overhead; nothing downstream needs to change when the adapter lands.
- **Histogram buckets / percentiles** — `Timer` only exposes as a Prometheus *summary* (`_count`/`_sum`); no
`histogram_quantile`-friendly buckets yet.
-- **Multiprocess aggregation** — `SimpleMeterRegistry` is a single-process, in-memory store; under PHP-FPM/Octane
- with multiple workers, each process/worker exposes only its own counters (no shared-memory or Redis aggregation
- layer, unlike `prometheus_client`'s APCu/Redis adapters).
+- **Multiprocess aggregation is opt-in, and partial.** `firefly.observability.metrics.store` gives counters,
+ timers and set-gauges cross-process totals through the cache (see [Surviving the
+ request](#surviving-the-request)); without it, `SimpleMeterRegistry` exposes only the calling process's own
+ meters. Two limits remain even with a store: the `counter()`/`timer()`/`gauge()` factory objects stay
+ process-local, and a pull-based gauge registered by `MeterBindingsPass` is sampled in whichever process serves
+ the scrape (which is the correct semantic for `php_memory_peak_bytes`, and the only possible one for a live
+ circuit-breaker read).
- **A second Octane management-port listener** — deferred alongside `firefly/actuator`'s own known-latent (no second
management port; doesn't fit PHP-FPM). An SP-7 option for Octane deployments.
diff --git a/docs/modules/openapi.md b/docs/modules/openapi.md
new file mode 100644
index 0000000..0623b85
--- /dev/null
+++ b/docs/modules/openapi.md
@@ -0,0 +1,778 @@
+# OpenAPI
+
+`firefly/openapi` generates a valid **OpenAPI 3.1** document from the manifests the framework already holds in
+memory. There is no annotation dialect to learn and nothing to keep in sync by hand: `RouteManifest` supplies the
+paths, verbs, statuses, route names and the per-parameter binding plan; `ConstraintManifest` supplies the
+request-body schemas and their `required` lists; `firefly/kernel`'s `ErrorResponse` supplies the RFC 9457 error
+component. Install the package and a LaraFly app has a spec — and therefore typed clients — for free.
+
+Because every fact in the document is read from the same compiled artifacts the dispatcher dispatches from and the
+validator validates with, **the spec cannot drift from the server**.
+
+`firefly/firefly` requires it, so a skeleton project already serves `/openapi` and `/openapi.json`. Add it
+directly if you took the packages à la carte:
+
+```bash
+composer require firefly/openapi
+```
+
+`firefly/firefly` requires it, so a project built from the skeleton already has it; the line above is for an
+application that took the packages à la carte.
+
+## What you get
+
+| Surface | Default | Purpose |
+|---|---|---|
+| `GET /openapi.json` | on | The generated OpenAPI 3.1 document, served as `application/json` |
+| `GET /openapi` | on | A browser API console — Swagger UI by default, from your own origin |
+| `GET /openapi/assets/{file}` | on | The Swagger UI distribution files, served from this application |
+| `php artisan firefly:openapi` | — | Writes the document to a file (`--output=`) or raw to stdout |
+
+The media type on the spec route is `application/json`, deliberately not the more precise
+`application/openapi+json;version=3.1`: that type is registered but poorly supported, and several of the generator
+toolchains this package exists to feed refuse a document whose `Content-Type` they do not recognise. The document
+says `"openapi": "3.1.0"` in its first member, which is how every consumer actually detects the version.
+
+## The routes are not attribute routes
+
+All three are mounted natively on the illuminate `Router` from `OpenApiRouteRegistrar`, a `BootPass` at
+`WiringPasses` order **60** — the `ActuatorRouteRegistrar` idiom, chosen for two independent reasons.
+
+First, **an attribute route cannot be configurable.** `#[GetMapping('/openapi.json')]` bakes its literal into a
+compiled `RouteDescriptor` at `firefly:cache` time, so an operator could never move the spec off a path that
+collides with one of their own, and could never take it off a public surface without deleting the package.
+
+Second, an attribute route would enter the application's `RouteManifest` — and the generator reads that manifest,
+so **the package would document itself.**
+
+`firefly.openapi.enabled` (default `true`) is enforced *there*, on the routes, rather than on the beans: the
+generator and its collaborators are inert without routes, so gating the routes is the whole of the switch. Turning
+it off leaves the paths genuinely **unrouted**, so they 404 through the router's own `NotFoundHttpException`, which
+`ProblemDetailsRenderer` renders as a proper `404` problem-details body rather than a 500.
+
+The actions are resolved *inside* each route closure (`$container->make(...)`), never captured at boot — capturing
+would freeze one `OpenApiGenerator` into the route for the process's lifetime, which is exactly the shape that
+breaks under Octane when a later request's container is a different sandbox.
+
+## What the generator maps
+
+**Operations** come from each `RouteDescriptor`: the verb and path (Laravel's optional `{id?}` is normalised to
+`{id}`, since a path parameter is *required* in OpenAPI), the `#[Mapping]`'s declared status, and the route name as
+the `operationId` when one is set — otherwise a derived `lcfirst() +
+ucfirst()`. A repeat claim is suffixed (`_2`, `_3`) rather than allowed to overwrite, because a duplicate
+`operationId` is the one flaw that makes most client generators abort rather than degrade. Operations are tagged by
+controller short name.
+
+**Parameters** come from the binding plan — the same `kind` discriminator `ArgumentResolver` dispatches on at
+request time. `#[PathVariable]`, `#[QueryParam]` and `#[RequestHeader]` become Parameter Objects; `#[UploadedFile]`
+becomes a `multipart/form-data` part; a container-injected service is not part of the HTTP contract and never
+appears.
+
+**Request bodies** come from the `#[RequestBody]` DTO, as a `$ref` into `components/schemas` — one component per
+DTO, reused everywhere, with nested `#[Valid]` DTOs given their own component rather than being inlined, so a
+self-referential DTO terminates as a `$ref` cycle instead of recursing forever. The whole derivation —
+declared types, compiled constraints, docblock prose — is
+[its own section below](#how-a-request-dto-becomes-a-schema).
+
+**Responses.** The success entry is keyed by the `#[Mapping]`'s declared status, and its body schema is derived from
+three sources, most specific first — see [What an endpoint returns](#what-an-endpoint-returns). A `204` (or a
+`void`/`never` return) gets no `content` at all, because emitting a content map for a status that carries no body is
+exactly what a strict client generator turns into a phantom return type.
+
+Beside it, every operation carries the shared `#/components/responses/Problem` as its `default`, plus a `400` when
+`ArgumentResolver` has something it can reject before the controller runs (a required binding, or one whose value
+must be *converted* out of the string the wire always carries — a `string` parameter cannot fail a conversion, an
+`int`/`float`/`bool`/enum can), and a `422` when a binding carries `#[Valid]`. `ProblemSchema` describes what
+LaraFly *actually* returns — RFC 9457's members **plus** Firefly's `code`, `category`, `severity` and `errors` —
+with the `category` and `severity` enumerations read straight off
+`ErrorCategory::cases()`/`ErrorSeverity::cases()`, so a new kernel case appears in the spec on the next generation
+with no edit in this package.
+
+**`#[Controller]` HTML routes are excluded by default.** They are part of the HTTP surface but not JSON API
+operations, and describing one as `application/json` hands a generator a typed client for a response that is a web
+page. `firefly.openapi.include-html` documents them anyway, as `text/html`.
+
+## What an endpoint returns
+
+Every success response used to be `{"type": "object"}` — an object with no members. A viewer renders that as a blank
+panel and `openapi-generator` turns it into `any`, so the most useful sentence an API document contains was the one
+sentence missing, for every endpoint of every application.
+
+The shape was never unavailable. It is written one line above the method, and **PHPStan at level max already checks
+it against the code on every build** — which is exactly what makes reading it safe. An out-of-date `@return` is a
+failing gate, not a silent lie. (It is also no different in kind from the input side: `RouteScanner` already reads
+`@param list` to compile the table `ArgumentResolver` hydrates from.)
+
+Three sources, in order:
+
+```php
+/**
+ * A page of orders.
+ *
+ * @return array{page: positive-int, size: positive-int, total: int, items: list}
+ */
+#[GetMapping]
+public function index(): array { /* … */ }
+```
+
+1. **The `@return` type expression.** The only place a PHP `array` can say what is *in* it. Prose after the type
+ becomes the response `description` — the only response description anyone actually writes.
+2. **The declared return type.** A class becomes a component `$ref`, a backed enum its value set, a scalar itself.
+3. **Neither** — `type: object`, the old behaviour, kept as the *fallback* for a bare `array` return with nothing
+ said about it. A `@return array` parses fine and means nothing, so it is treated as saying nothing
+ rather than allowed to suppress what the declared type knew.
+
+### The type expressions it understands
+
+`Firefly\OpenApi\Schema\DocType` is a small recursive-descent compiler from a PHPDoc type expression to a JSON
+Schema fragment. It is used for `@return`, for `@param`/`@var` on collection members, and for
+`#[ApiResponse(type:)]`.
+
+| Written | Becomes |
+|---|---|
+| `list`, `Order[]`, `array` | `type: array` with `items: {$ref: Order}` |
+| `array` | `type: object` with `additionalProperties: {$ref: Money}` |
+| `array{a: int, b?: string}` | an object with `properties`, `required: [a]` and `additionalProperties: false` |
+| `array{a: int, ...}` | the same, but open — the `...` is the only thing that lifts `additionalProperties: false` |
+| `array{int, string}` | `prefixItems`, with `minItems`/`maxItems` — a tuple |
+| `'draft'\|'sent'` | `type: string` with `enum` |
+| `?Order`, `Order\|null` | `anyOf: [{$ref}, {type: null}]` |
+| `non-empty-string`, `positive-int` | `minLength: 1`, `minimum: 1` |
+| `mixed` | `{}` — the any-value schema, a real answer |
+| `never`, `callable`, an unresolvable name | **nothing**, so the caller falls back to what it already knew |
+
+A `?` on a shape KEY (`b?: string`) means "may be absent" and becomes `required`; a `?` on the VALUE means "may be
+null" and becomes the type union. Conflating them documents an omissible member as one a client must always send.
+
+Class names resolve through the **imports of the file the expression was written in** — reflection does not expose a
+file's `use` statements, so they are read from the source. Without that, only fully-qualified names would work,
+which is the one spelling nobody writes.
+
+### A returned class becomes a component
+
+`ResponseSchemaFactory` builds it from the **wire shape** — what `json_encode` emits — which is not the same thing as
+the request side's constructor:
+
+- A class implementing `JsonSerializable` serialises as whatever `jsonSerialize()` **returns**. Give that method a
+ `@return array{…}` and the schema is exact. The skeleton's `App\Orders\Order` is the case that matters: it
+ publishes a derived `total` that is a *method*, so reflection alone would document five of the six members the API
+ actually sends.
+- Everything else serialises as its **public properties**, which is what reflection reads.
+- A declared shape only wins when it says something. `@return array` on `jsonSerialize()` means "an
+ object, members unknown" — strictly less than the property list it would have suppressed, so it is ignored.
+
+Nullability is not requiredness here. A response member is present or absent, and `?int $id` is always *present* and
+sometimes null — so response members stay `required` and nullable ones widen their type. The request side's rule
+would have told every client to expect an absence that never happens.
+
+### `#[ApiResponse]` takes a type expression
+
+```php
+#[PostMapping(status: 201)]
+#[ApiResponse(status: 409, description: 'That reference already exists.', type: Consignment::class)]
+#[ApiResponse(status: 202, description: 'Accepted for later booking.', type: 'list')]
+public function book(): array { /* … */ }
+```
+
+`type` is a full expression, not only a class or scalar name, and a short name resolves through the controller's own
+imports.
+
+## How a request DTO becomes a schema
+
+A `#[RequestBody]` DTO is turned into a `components/schemas` entry by `DtoSchemaFactory`, from **three sources that
+each know a different part of it** — and no two of which can be derived from the other:
+
+| Source | Knows | Does not know |
+|---|---|---|
+| `ConstraintManifest` — the compiled rules | which members are required, what shapes they must have | types: a rule list is untyped by construction |
+| The **constructor signature**, by reflection | `?int`, a backed enum, a nested DTO, a default value | constraints: they live in attributes the manifest has already digested |
+| The **docblock**, plus `#[ApiProperty]` | what the member *means*, an example, a more precise `format` | everything above |
+
+Neither of the first two alone produces a usable schema. Types-only documents `#[NotBlank] string $name` as an
+unbounded string; constraints-only documents `int $quantity` as a string.
+
+### Why the manifest, and not the `#[Constraint]` attributes
+
+Reading the attributes back off the DTO is the obvious route to `#[Email]` → `format: email`, and it would document
+a validator that does not exist. `ConstraintManifest::rulesFor()` returns the exact
+`list` the `BeanValidator` is handed at request time, and by the time it does, the scanner
+has already:
+
+* applied the **Jakarta null contract** — a `nullable` flag prepended to every property whose declared type admits
+ null and which carries no `NullAware` rule;
+* expanded `#[Size]` into a first-party rule **object** rather than Laravel's polymorphic `min:`/`max:` strings;
+* flattened one `#[Valid]` level into dotted keys (`beneficiary.postcode`).
+
+Generating from the attributes would re-derive all of that by hand and drift from it the first time
+`packages/validation` changed a `toRules()` body. Generating from the manifest cannot drift, because the manifest
+**is** the contract.
+
+### The property list, and why there is no `additionalProperties: false`
+
+The members documented are the **constructor's parameters, in declaration order** — exactly what `ArgumentResolver`
+hydrates from. It picks the compiled binding's property list out of the decoded body and splats those keys as named
+arguments; keys outside the list are **silently ignored, not rejected**. So no `additionalProperties: false` is
+emitted: the server genuinely accepts extra members, and a spec claiming otherwise would make conforming clients
+fail requests the server would have served.
+
+A member the constructor does *not* take is still documented when the manifest carries rules for it, because
+`BeanValidator` validates the raw decoded array — such a member is enforced on input even though nothing hydrates
+it.
+
+An empty `required` array is **omitted** rather than emitted: `required: []` is invalid under the OpenAPI 3.1
+meta-schema (`minItems: 1`), and strict validators do enforce it.
+
+### The type half
+
+`TypeSchema` handles everything derivable from a type *name* alone. Three shapes get first-class treatment because
+a JSON client has to decode them differently and all three are invisible to the constraint list:
+
+| Declared type | Fragment |
+|---|---|
+| `string` / `int` / `float` / `bool` | `type: string` / `integer` / `number` / `boolean` |
+| `array`, `iterable` | `type: array` |
+| A **backed enum** | `enum: [...]` over the backing values, plus `type: integer` when every case backs an int, else `type: string` |
+| `DateTimeInterface` (or any implementor) | `type: string`, `format: date-time` |
+| `mixed`, `object`, `null`, untyped, or a class this process cannot autoload | `{}` — the "any JSON value" schema, **never** a guessed `type: string` |
+| Any other class | *no fragment* — the caller mints a `$ref` instead |
+
+The backed-enum row is the single highest-value thing the reflection buys: `Currency $currency` documents the exact
+accepted set, where the constraint list — usually empty on an enum-typed property, because the type already
+constrains it — would have documented an unbounded string.
+
+### Requiredness is wider than the constraints say
+
+`MemberType::required()` is deliberately broader than the constraint-derived answer:
+
+```php
+public function required(): bool
+{
+ return ! $this->hasDefault && ! $this->nullable && $this->type !== null;
+}
+```
+
+A constructor parameter with no default whose type does not admit null **cannot be omitted**: `ArgumentResolver`
+splats only the keys the body actually carried, so a missing one raises `ArgumentCountError` inside `new $dto(...)`
+— a 500, *after* validation has already passed. Documenting it as optional would hand every generated client a
+legal-looking request the server cannot serve. So the PHP signature is treated as the requirement it genuinely is,
+alongside whatever `#[NotNull]`/`#[NotBlank]` say.
+
+JSON Schema states requiredness on the **parent** object, never on the member, which is why the mapper returns a
+`PropertySchema` — a schema *plus* that one boolean — rather than a schema alone.
+
+### Constraints to keywords
+
+`ConstraintSchemaMapper` walks the compiled rule list and layers keywords onto whatever the declared type already
+produced. **First writer wins**, everywhere: the type fragment is seeded before any rule is seen, so
+`#[Min(1)] int $quantity` keeps `type: integer` instead of being widened to `number` by the `numeric` rule string
+`#[Min]` emits — which would wrongly document `1.5` as acceptable. The same ordering then applies among the rules
+themselves, matching declaration order, which is the order the validator applies them in.
+
+Each attribute below is shown with the compiled rule it actually produces, because that rule — not the attribute —
+is what the mapper sees:
+
+| Constraint | Compiles to | JSON Schema |
+|---|---|---|
+| `#[NotNull]` | `present` + `NotNull` rule | `required` **and** clears nullability — the one rule that answers both questions |
+| `#[NotEmpty]` | `required` | member added to the parent's `required` |
+| `#[NotBlank]` | `required`, `string`, `regex:/\S/` | `required` + `type: string` + `pattern: \S` |
+| `#[Size(min, max)]` | `Size` rule object | `minLength`/`maxLength`, or `minItems`/`maxItems` when the type is `array` |
+| `#[Min(n)]` / `#[Max(n)]` | `numeric` + `gte:n` / `lte:n` | `minimum` / `maximum` |
+| `#[Positive]` / `#[PositiveOrZero]` | `numeric` + `gt:0` / `gte:0` | `exclusiveMinimum: 0` / `minimum: 0` |
+| `#[Negative]` / `#[NegativeOrZero]` | `numeric` + `lt:0` / `lte:0` | `exclusiveMaximum: 0` / `maximum: 0` |
+| `#[Digits(i, f)]` | `numeric` + a `regex:` bounding both parts | `type: number` + `pattern` |
+| `#[Pattern(re)]` | `regex:re` | `pattern`, PCRE delimiters stripped |
+| `#[Email]` | `email` | `type: string`, `format: email` |
+| `#[UuidValue]` | `Uuid` rule | `type: string`, `format: uuid`, `pattern` |
+| `#[Phone]` | `E164` rule | `type: string`, `format: phone`, `pattern: ^\+[1-9]\d{1,14}$` |
+| `#[CurrencyCode]` | `Currency` rule | `type: string`, `format: currency`, `pattern: ^[A-Z]{3}$` |
+| `#[CountryCode]` | `CountryCode` rule | `type: string`, `format: country-code`, `pattern: ^[A-Z]{2}$` |
+| `#[LanguageTag]` | `LanguageTag` rule | `type: string`, `format: bcp47`, `pattern` |
+| `#[PostalCode]` | `PostalCode` rule | `type: string`, `format: postal-code`, `pattern` |
+| `#[Iban]` | `Iban` rule | `type: string`, `format: iban` — **no pattern**, plus `iban:checksum` in the extension |
+| `#[Swift]` / `#[Bic]` | `Swift` / `Bic` rule | `type: string`, `format: swift` / `bic` — no pattern |
+| `#[Cusip]` / `#[Isin]` | `Cusip` / `Isin` rule | `type: string`, `format: cusip` / `isin`, plus `…:check-digit` in the extension |
+| `#[RoutingNumber]` | `RoutingNumber` rule | `type: string`, `format: aba-routing-number`, plus the check digit in the extension |
+| `#[Luhn]` | `Luhn` rule | `format: luhn` **only** — no `type`, since Luhn says nothing about it — plus the check digit in the extension |
+| `#[Percentage]` | `Percentage` rule | `type: number`, `minimum: 0`, `maximum: 100` |
+| `#[Money]` | `PositiveMoney` rule | `type: number`, `exclusiveMinimum: 0`, `multipleOf: 0.01` |
+| `#[DecimalScale(n)]` | `DecimalScale` rule | `multipleOf` — `0.01` for scale 2, `1` for scale 0 |
+| `#[AssertTrue]` / `#[AssertFalse]` | `accepted` / `declined` | `type: boolean` + `const: true` / `false` |
+| `#[Future]` / `#[Past]` | `date` + `after:now` / `before:now` | `type: string`, `format: date-time`; the temporal half lands in the extension |
+
+`multipleOf` is computed as a division rather than `10 ** -$scale` so the value round-trips through `json_encode`
+as `0.01` instead of `1.0E-2` — both are legal JSON numbers, but only the first reads as money in a rendered spec.
+
+Raw Laravel strings reach the same table through the `#[Rules]` escape hatch, and a few only exist there:
+
+| Rule string | JSON Schema |
+|---|---|
+| `nullable` | sets the nullable flag (see below) |
+| `required`, `present`, `filled` | member added to the parent's `required` |
+| `string`, `numeric`, `integer`/`int`, `boolean`, `array` | the corresponding `type` |
+| `url`, `active_url` | `type: string`, `format: uri` |
+| `ip` | `type: string`, `format: ipv4` |
+| `date`, `date_format` | `type: string`, `format: date-time` |
+| `gte:` / `lte:` / `gt:` / `lt:` | `minimum` / `maximum` / `exclusiveMinimum` / `exclusiveMaximum` |
+| `min:` / `max:` / `between:a,b` / `size:` | **polymorphic** — see below |
+| `in:a,b,c` | `enum` |
+| `accepted` / `declined` | `type: boolean` + `const` |
+
+Laravel's `min:`/`max:`/`between:`/`size:` are deliberately polymorphic — `Validator::getSize()` reads the *value*
+for a numeric attribute and the *length/count* otherwise — so what they translate to depends on the type already
+resolved for the property: `minimum`/`maximum` for a numeric one, `minLength`/`maxLength` for a string,
+`minItems`/`maxItems` for an array. Firefly's own `#[Size]` no longer emits these (it compiles to a rule object
+precisely because the polymorphism was a defect), but `#[Rules('min:3')]` passes the raw string straight through, so
+the ambiguity is still reachable and is resolved here exactly as the validator resolves it.
+
+An argument that is not numeric is not a bound at all — `gte:other_field` is a field reference JSON Schema cannot
+express — so it is recorded in the extension rather than coerced to `0`.
+
+### Nullability, patterns, and the extension
+
+**Nullability** is spelled the 3.1 way. OpenAPI 3.1 *is* JSON Schema 2020-12, which dropped 3.0's `nullable: true`
+in favour of a type union: `type: [string, "null"]`. A schema with no `type` at all already admits null and is left
+alone; an `enum` additionally gains a `null` member, because widening `type` alone would leave `null` failing the
+enumeration.
+
+**Patterns** are translated from PCRE (delimiters plus flags, the form every `regex:` rule carries) to the bare
+ECMA-262 body the `pattern` keyword expects. `D` and `u` are dropped as genuine no-ops — ECMA `$` without `m`
+already anchors at end-of-input, and JSON Schema patterns are already Unicode. Any *other* flag, `i` above all,
+cannot be carried across, so the pattern is still emitted (it is the closest true statement available) **and** the
+original rule is recorded in the extension, so a reader can see the published pattern is stricter than the server's.
+An unparseable pattern is recorded and otherwise ignored — a malformed `pattern` keyword breaks every consumer of
+the document, which is far worse than an absent one.
+
+JSON Schema has exactly **one** `pattern` slot per schema object, and `#[NotBlank]` + `#[Pattern]` on the same
+property genuinely produces two. One pattern becomes `pattern`; several become an `allOf` of single-pattern
+subschemas. Collapsing them by keeping the last would silently drop the non-blank guarantee.
+
+**Nothing is dropped silently.** Constraints JSON Schema cannot express (`after:now` — it cannot say "in the
+future"; a bare Luhn checksum; a third-party `ValidationRule`, recorded by class name because that is the only
+thing knowable about it without executing it) and ones it can only approximate are recorded under the
+`x-firefly-constraints` specification extension. Extensions are explicitly permitted by OpenAPI 3.1 and ignored by
+every conforming tool, so the document stays valid while the full truth survives for a human or a custom generator
+to read.
+
+!!! note "Where a `format` is invented, and where a `pattern` is withheld"
+ `format` in JSON Schema 2020-12 is an **open vocabulary** — unknown values are annotations, not errors. IBAN,
+ BIC, ISIN, CUSIP and E.164 have no registered format name, so self-describing ones are emitted (`iban`, `bic`,
+ …). The `pattern` is emitted only where the rule matches its PCRE against the **raw** value. Where the rule
+ normalises first — `Iban` strips spaces and upper-cases; `Bic`/`Swift`/`Cusip`/`Isin` upper-case;
+ `Luhn`/`RoutingNumber` strip separators — the pattern is deliberately withheld, because publishing the
+ post-normalisation pattern would reject payloads the server accepts. Under-specifying is the lesser error.
+
+### Nested DTOs are `$ref` components, never inlined
+
+A member whose declared type is a class that `TypeSchema` does not resolve becomes its own component and a `$ref`.
+`SchemaRegistry` exists for the two problems an inlining generator has:
+
+**Duplication.** A DTO used by six operations would be emitted six times, and every generated client would mint six
+structurally identical anonymous types with six different names. Registering once and referring by `$ref` is what
+makes `openapi-generator`/`orval`/`kiota` produce **one named type per DTO**, which is the whole point of
+generating the document.
+
+**Recursion.** `SelfReferential { #[Valid] ?SelfReferential $parent; }` cannot be inlined at all — the expansion
+does not terminate. So `ref()` **reserves the component name before invoking the builder**, and a nested call for
+the same class finds the name taken and returns the reference immediately, closing the cycle.
+
+Component **names** are the class's short name, because that is what a human reads in a viewer and what a generator
+turns into a type name. Two DTOs sharing a short name across namespaces (`Order\Dto\Address` and
+`Billing\Dto\Address`) would collide, so the **second** claimant falls back to its dotted fully-qualified name —
+ugly, unambiguous, and rare. First claimant wins, so adding a second `Address` elsewhere never renames the one
+already published.
+
+A **nullable** nested DTO is spelled as the union it actually is:
+
+```json
+{ "anyOf": [ { "$ref": "#/components/schemas/Address" }, { "type": "null" } ] }
+```
+
+not as a `$ref` with a sibling `type`. In 2020-12 a *validation* keyword beside a reference is applied **with** it,
+so `type: "null"` would have to pass as well as the reference and could never hold. Annotations are the opposite
+case — a `description` beside a `$ref` is legal — which is why the prose below is applied to either shape.
+
+Where the nested class has its own manifest entry (the normal case: the compiler compiles every class under the
+app's scan roots, not just body DTOs) its own rules are used. Where it does not, the parent's dotted
+`#[Valid]`-cascaded keys are **unflattened back into it**, so a nested schema is still constrained rather than a
+bare `type: object`.
+
+### `list` element types come from the constructor docblock
+
+PHP's `array` says nothing about what is in it, so `#[Valid] public readonly array $lines = []` documented itself
+as a bare `type: array` with no `items` — which a client generator faithfully turns into `Array`, a typed
+client with an untyped hole in exactly the member that most needed a type.
+
+The element class is not missing information, though. It is written in the **constructor docblock**, and
+`packages/web` already reads it: `RouteScanner::dtoShapes()` resolves it at `firefly:cache` time and compiles it
+into the body binding's `dtos` table so `ArgumentResolver` can hydrate the nested payload without reflecting.
+That table is a `class => member => {class, list}` map covering **every class reachable from the body DTO, at any
+depth**, and it is the first thing the generator consults — because it is not a copy of the answer, it *is* the
+answer the hydrator uses. A document generated from it cannot describe a shape the server would refuse to build.
+
+Three spellings are recognised, and they all mean the same payload:
+
+```php
+/**
+ * @param list $lines The lines to order, at least one.
+ * @param OrderLineRequest[] $legacy The same thing, the older way.
+ * @param array $channels A keyed array works too; the key type is ignored.
+ */
+public function __construct(
+ #[Valid] public readonly array $lines = [],
+ public readonly array $legacy = [],
+ public readonly array $channels = [],
+) {}
+```
+
+A short name is resolved the way PHP would resolve it: an already-qualified name as-is, then the declaring class's
+own namespace, then the file's `use` imports. A name that does not resolve to a real class is **dropped entirely**
+rather than emitted as a dangling `$ref` — the same choice `RouteScanner` makes when it leaves such a member out
+of the hydration table.
+
+Only a parameter **declared `array`** may take an element type from a comment. A class-typed member is a nested
+DTO already resolved from its declared type, and `iterable` is excluded because the scanner excludes it: giving
+`items` to a member the hydrator does not bind as a list would describe a request the server cannot accept.
+
+What the element becomes depends on what it is:
+
+| Element | `items` |
+|---|---|
+| A DTO | `{"$ref": "#/components/schemas/OrderLineRequest"}` — its own component, like any nested DTO |
+| A backed enum, a `DateTimeInterface`, a scalar | **inlined** — an enum is not a reusable component, and minting one per enum would hand every generated client a named type where an inline union is what the payload is |
+| Something `TypeSchema` cannot resolve | no `items` at all, rather than an empty `{}` — both say "any element", and the absent one avoids a later `[]`-vs-`{}` decision |
+
+### Everything else a PHP `array` can be
+
+The table above answers for a list of **classes**, which is what the hydrator's compiled table knows about. Three
+collections it does not cover were published as a bare `type: array` for the same reason `list` once was:
+
+| Written | Was | Is |
+|---|---|---|
+| `list $tags` | `type: array` — `Array` again | `items: {type: string}` |
+| `list> $matrix` | `type: array` | nested `items` |
+| `array $meta` | `type: array` — **the wrong JSON type** | `type: object` with `additionalProperties` |
+
+The third is the one that mattered. `array` is a JSON *object*; publishing it as an array is not
+merely vague, and a generated client fails to decode the payload the server actually sends.
+
+The expression is read by [`DocType`](#the-type-expressions-it-understands) **after both element-type paths have
+declined** — the compiled table and its reflection mirror — so the same step runs whichever path was taken, and the
+hydrator's answer still wins wherever it has one. That placement is the whole design: the original reason for
+publishing nothing here was drift between two implementations of one rule, and running afterwards is what makes a
+third implementation impossible.
+
+A `#[Size]` on a map then had to stop emitting `minLength`, which is not a constraint on an object at all — a
+validator ignores it, so the document would silently drop a bound the server does enforce. `lengthKeyword()` now
+knows three shapes: `minItems` for a list, `minProperties` for a map, `minLength` for a string.
+
+A list of DTOs recurses safely for the same reason a plain nested DTO does: `SchemaRegistry` reserves the
+component name *before* the builder runs, so `CategoryNode { list $children }` closes its own cycle
+on the component being built instead of expanding forever.
+
+`items` is seeded into the **base** fragment rather than layered on afterwards, so the constraint mapper's
+first-writer-wins ordering sees a complete declared-type fragment — and so a `#[Size]` on the member still
+resolves against the `type: array` sitting beside it and becomes `minItems`/`maxItems` rather than
+`minLength`/`maxLength`.
+
+!!! note "Rules for a list element come from the element's own manifest entry"
+ Never from the parent's dotted `#[Valid]` keys. `ConstraintScanner` cascades a `#[Valid]` only through a
+ **class-typed** member, so a parent's dotted keys can never describe a list element in the first place — and
+ unflattening a Laravel-style `lines.*.sku` into an element schema would invent a member literally named
+ `*.sku`. The element is constrained because the compiler compiled *its* class too, not because its parent
+ mentioned it.
+
+!!! warning "There is a second, reflective path — and it is only ever a fallback"
+ Three reachable shapes carry no compiled table: a DTO named by `#[ApiResponse(type:)]` (a response has no
+ binding plan at all), a DTO handed straight to `DtoSchemaFactory::ref()` by something other than a request
+ body, and a route manifest compiled before the scanner emitted the `dtos` key — a supported state, since that
+ key is written only when a body DTO actually nests. In all three the element type is still sitting in the
+ docblock, and the choice is between reading it and shipping `Array` again. The scanner's resolution is
+ private to `packages/web` and reachable only through a compiled binding, so it is **mirrored** rule for rule.
+ Two implementations of one rule is a real cost; the alternative was a generator whose output silently
+ depended on whether a route happened to reach the class. The mirror is deliberately *not* consulted when the
+ table has a row for the class: a row is complete, so a member missing from it is a member the hydrator will
+ not treat as a list, and second-guessing that with reflection is how the two paths would drift.
+
+### The prose half
+
+The schema's `description` is the DTO's class docblock. A member's is resolved in this precedence:
+
+1. `#[ApiProperty(description:)]` — the author said it explicitly;
+2. the member's **own** docblock;
+3. the constructor's `@param` line for it.
+
+That order is the one people expect from reading a file top to bottom — the closer a statement sits to the member,
+the more specific it is. The `@param` fallback matters more than it looks: a promoted constructor property is where
+most LaraFly DTOs put everything, and `@param` is the only place PHPDoc lets you describe one without inventing a
+property docblock for a parameter.
+
+**Nothing is invented.** A member with no description in any of the three sources gets **no `description` key**,
+rather than a humanised restatement of its own name — `"quantity": {"description": "Quantity"}` is noise that costs
+a reader a second to dismiss and costs the file a line per property forever. The schema-level fallback is the one
+exception: a DTO with no class docblock gets `Request payload bound from App\Dto\X.`, which is a *locator* telling
+you which PHP file to open, not documentation — which is exactly why any real docblock beats it.
+
+`#[ApiProperty]`'s `format` **overwrites** a constraint-derived one, on the grounds that an author naming a format
+is making the more precise statement. Examples are emitted as the **plural array** form, `examples: [...]`: 3.1
+aligned the Schema Object with JSON Schema 2020-12, whose keyword is `examples`, and explicitly deprecated the
+singular `example` inherited from 3.0.
+
+A constructor **default** is copied into `default` only when it is a JSON value — a scalar, `null`, or a list of
+scalars. An object or enum default (a promoted `new Money(0)`, say) has no JSON spelling a client could send back,
+and emitting a serialised approximation would be a `default` the server never applies.
+
+## Determinism, and the `{}`-vs-`[]` trap
+
+Paths are sorted, verbs within a Path Item are sorted into the canonical OpenAPI order, and `SchemaRegistry` sorts
+components by name. Route discovery order depends on filesystem iteration, so an unsorted document would reshuffle
+itself between machines and turn every regeneration into an unreviewable diff — which is what makes teams stop
+committing the generated file, which is what makes it go stale.
+
+`generate()` returns plain PHP arrays (pleasant to assert against); `toJson()` is the canonical serialisation and
+the one that must produce any file or HTTP body. PHP cannot tell an empty map from an empty list, so
+`json_encode([])` is `[]` — and `"paths": []` or an unconstrained property serialised as `[]` are both type errors
+against the 3.1 meta-schema that make a strict validator reject an otherwise perfect document. `toJson()`
+therefore re-encodes empty arrays as `{}`.
+
+That rewrite used to be **unconditional**, justified by a claim that quietly stopped being true — "nothing in this
+document ever emits an empty *list*". A constructor default does. `array $lines = []` is documented as
+`default: []`, the rewrite turned it into `"default": {}`, and the document then told every client that omitting
+`lines` yields an empty **object** for a member the same schema declares `type: array` two lines above. A generated
+client either fails to compile against its own type or ships a wrong default.
+
+The fix draws the line the rewrite always meant to draw, between **structure** and **data**. `default`, `const` and
+`example` hold one instance value; `enum` and `examples` hold a list of them. Those are values the schema
+*describes*, not part of the document's own shape, so an empty one is typed by the sibling `type`: `type: array`
+(or the 3.1 nullable spelling `type: [array, "null"]`) makes it a JSON array, and anything else falls back to the
+structural `{}`.
+
+Requiring the schema to have *said* `array`, rather than trusting the PHP value, is what keeps the exception
+narrow. Those keywords are also perfectly legal DTO member names, so `properties: {"default": {}}` is a reachable
+node, and a rule of "an empty array under one of these keys is always a list" would turn that member's own empty
+schema into an invalid `[]`. The cost is one genuinely ambiguous case — a `mixed` member with an array default,
+which declares no type for anything to decide from. Nothing recurses into a *non-empty* instance either:
+`json_encode`'s own list-vs-map rule is already right for it, and rewriting a caller's example payload would
+corrupt their empty arrays.
+
+Everything structural still holds: `required`, `tags`, `parameters`, `servers`, `allOf` and the constraint
+extension are each omitted entirely rather than emitted empty.
+
+## `php artisan firefly:openapi`
+
+```bash
+php artisan firefly:openapi --output=docs/openapi.json # writes the file, prints a summary line
+php artisan firefly:openapi > openapi.json # writes the raw document to stdout
+```
+
+The command exists so the document can be a **build artifact** rather than only a live endpoint. Committing the
+generated file is what lets a CI job diff it and fail a pull request that changed the public API without saying so,
+and what lets a front-end repository regenerate its typed client from a checked-in spec without booting the PHP
+application at all. It is also the only way to get a document out of a deployment that keeps
+`firefly.openapi.enabled` off in production.
+
+Stdout is written with Symfony's `OUTPUT_RAW`, and that detail is load-bearing: console output normally goes
+through Symfony's formatter, which treats `<…>` as markup, so any angle bracket reaching the document from a
+docblock or a config value would either be swallowed or throw on an unknown tag. The point of stdout mode is
+`firefly:openapi | `, so the bytes must be exactly the bytes of the document. It is also why the
+confirmation line prints **only** in `--output` mode, where stdout is not the document.
+
+Parent directories of `--output=` are created; a failure to create or write reports an error and returns a non-zero
+exit code.
+
+## The viewer, and the three styles
+
+`GET /openapi` renders a browser console. `firefly.openapi.viewer.style` selects which one, and only one of the
+three makes a request to a third party.
+
+| `style` | Ships from | Third-party request at page view? | Notes |
+|---|---|---|---|
+| `swagger` **(default)** | your own origin, out of the `swagger-api/swagger-ui` composer package | **no** | The official Swagger UI, byte-for-byte |
+| `builtin` | inline in the response | **no** | Hand-written, no third-party JavaScript at all |
+| `cdn` | `cdn.jsdelivr.net` | **yes, on every view** | Swagger UI at a pinned version; no SRI claimed |
+
+An unrecognised value falls back to `swagger` rather than rendering a blank page.
+
+### Why `swagger` from your own origin is the default
+
+Every off-the-shelf viewer — Swagger UI, Redoc, Elements — is a bundled JavaScript application, which historically
+left a PHP package two options: vendor a multi-megabyte bundle into its own git history, or fetch it from a CDN on
+every page view. The second is a supply-chain dependency and a data-protection question, and it renders **nothing
+at all** in the air-gapped and strict-CSP environments where an internal API console is most wanted.
+
+`swagger-api/swagger-ui` publishes the `dist` on Packagist under Apache-2.0, so there is a third option and this
+package takes it: composer fetches and pins the official distribution, and `SwaggerAssetAction` serves it from the
+application's own origin. No CDN, no npm, no bundle in this repository's history, and the UI is exactly the one
+Swagger publishes — full feature set, deep linking, try-it-out, OAuth2 redirect.
+
+`swagger-api/swagger-ui` is a hard `require` of `firefly/openapi`, so the files are already on disk. If they are
+somehow absent — a stripped `vendor/`, a phar, a non-composer runtime — `ViewerPage` falls back to `builtin` rather
+than rendering a console whose assets 404.
+
+Asset serving is a **whitelist**, not a sanitiser: only seven basenames are servable
+(`swagger-ui.css`, `swagger-ui-bundle.js`, `swagger-ui-standalone-preset.js`, `oauth2-redirect.html`,
+`favicon-16x16.png`, `favicon-32x32.png`, `index.css`), each resolved path is `realpath()`-checked to be inside the
+dist directory, and the route itself constrains `{file}` to `[A-Za-z0-9._-]+` so it cannot even express a
+traversal. A whitelist cannot be defeated by an encoding trick a sanitiser missed. Anything else is a plain 404
+(`text/plain`, deliberately not problem+json — the caller is a browser fetching a stylesheet, not an API client).
+Assets are immutable for a pinned version, so they are sent `public, max-age=31536000, immutable` with an auto
+ETag; composer changes the bytes only when the pinned version changes.
+
+### What `cdn` costs
+
+```php
+'openapi' => ['viewer' => ['style' => 'cdn']],
+```
+
+Every page view then loads Swagger UI from `cdn.jsdelivr.net`. The version is pinned exactly; **no Subresource
+Integrity hash is claimed**, deliberately — a hash the framework cannot verify at release time is security theatre,
+and a wrong one simply breaks the page. In exchange for a third-party request, a CSP that must allow `cdn.jsdelivr.net`,
+and a console that renders nothing in an air-gapped deployment, you get… the same Swagger UI `swagger` already gave
+you from your own origin. The style is kept for parity with what most tutorials show, and because some deployments
+prefer their bytes to come from a cache they already trust.
+
+`firefly.openapi.viewer.cdn` is the older boolean spelling of this. It still **forces** the CDN page and wins over
+`style`, so an application that set it before `style` existed keeps the behaviour it configured; prefer `style` in
+new configuration.
+
+### What `builtin` is for
+
+A hand-written, dependency-free reference: one inline `
+@endpush
diff --git a/packages/admin/resources/views/data-map.blade.php b/packages/admin/resources/views/data-map.blade.php
new file mode 100644
index 0000000..0cfc9d4
--- /dev/null
+++ b/packages/admin/resources/views/data-map.blade.php
@@ -0,0 +1,159 @@
+@extends('firefly-admin::layout')
+@section('title', 'Entity map')
+@section('body')
+ @php
+ use Firefly\Admin\Format;
+
+ // LAYOUT. Boxes on a grid, one row per level, centred within the widest row. An entity box has to
+ // show its columns, so its height is content-driven and the row height is the tallest box in it —
+ // a fixed height would clip a wide table or leave a lake of whitespace under a narrow one.
+ $boxW = 236;
+ $gapX = 54;
+ $gapY = 96;
+ $headH = 42;
+ $rowH = 17;
+ $padY = 10;
+
+ $byLevel = [];
+ foreach ($map->nodes as $node) { $byLevel[$node['level']][] = $node; }
+ ksort($byLevel);
+
+ $height = static fn (array $n): int => $headH + $padY + $rowH * (count($n['columns']) + ($n['more'] > 0 ? 1 : 0));
+
+ $widest = 0;
+ foreach ($byLevel as $row) { $widest = max($widest, count($row)); }
+ $canvasW = max(1, $widest) * $boxW + max(0, $widest - 1) * $gapX + 40;
+
+ $placed = [];
+ $y = 20;
+ foreach ($byLevel as $row) {
+ $rowW = count($row) * $boxW + (count($row) - 1) * $gapX;
+ $x = (int) (($canvasW - $rowW) / 2);
+ $tallest = 0;
+
+ foreach ($row as $node) {
+ $h = $height($node);
+ $tallest = max($tallest, $h);
+ $placed[$node['slug']] = ['x' => $x, 'y' => $y, 'w' => $boxW, 'h' => $h, 'node' => $node];
+ $x += $boxW + $gapX;
+ }
+
+ $y += $tallest + $gapY;
+ }
+ $canvasH = $y;
+ @endphp
+
+
+
Entity map
+
Every browsable entity and the foreign keys between them, from the same discovery the data browser
+ walks. A hasMany and the belongsTo facing it are one key seen from two ends, so each is drawn once,
+ pointing from the table that holds the key to the table it references.
+
+
+
+
Entities
{{ count($map->nodes) }}
+
Foreign keys
{{ count($map->edges) }}
+
Levels
{{ count($map->levelsPresent()) }}
+
Cycles
{{ count($map->cycles) }}
+
+
+ @if ($map->isEmpty())
+
+ @include('firefly-admin::_empty', [
+ 'title' => 'No entities to map',
+ 'body' => 'No bean implements CrudRepository, so there is nothing to draw. Declare a
+ repository — extends EloquentRepository plus a model name — and it
+ appears here and in the browser at the same time.',
+ ])
+
+ A box is a link: open it to browse that entity's records. Columns are the first
+ {{ 8 }} the schema reports, with the identifier marked; a key's own name is drawn on the line
+ it belongs to. Relations the browser cannot express as one column — a pivot, a polymorphic
+ type column — are listed on each record page but are not drawn here, because a line with no
+ join to name would be decoration.
+
Two tables that reference each other. Legal, and usually a nullable key on one
+ side — but worth knowing about, because it is also what makes a delete order ambiguous.
+ @include('firefly-admin::_empty', [
+ 'title' => 'No such record',
+ 'body' => 'It may have been deleted, or the resource '.e($slug).' has no identifier column to address one by.',
+ ])
+
+ @include('firefly-admin::_empty', [
+ 'title' => 'The browser is read-only',
+ 'body' => 'Set firefly.admin.data.writable to permit writes. It is a separate key from
+ firefly.admin.data.enabled on purpose: switching the browser on never
+ silently makes it writable.',
+ ])
+
+ @elseif (! $resource->isEloquentBacked())
+
+ @include('firefly-admin::_empty', [
+ 'title' => 'This resource cannot be created from here',
+ 'body' => 'Its entity is not an Eloquent model, and a generic form cannot honour an arbitrary
+ constructor’s invariants — a required value the form does not know about, an
+ argument order it cannot guess. Records for it belong to your own use cases. Editing
+ an existing one is refused for the same reason.',
+ ])
+
+ @else
+
+ @include('firefly-admin::_panel-head', [
+ 'title' => 'Fields',
+ 'count' => count(array_filter($schema->columns, fn ($c) => $c->isEditable())),
+ ])
+
+ {{-- The identifier and any masked column are absent from the form, not disabled in it: a field
+ the browser would refuse to write is a field it should not appear to accept. --}}
+
+ The identifier is assigned by the database, and masked columns are never written from here —
+ both are omitted rather than shown and ignored.
+
+
+ @foreach ($relations as $relation)
+ @php
+ // A to-one carries the key on THIS row and opens one record; a to-many carries it
+ // on the other table and opens that listing filtered by this row's key. The two
+ // produce different URLs from the same relation, which is why the direction is
+ // recorded rather than inferred in the template.
+ $value = $relation->toMany
+ ? ($record->fields[$relation->target] ?? $record->id)
+ : ($record->fields[$relation->column] ?? null);
+
+ $href = null;
+ if ($relation->navigable() && $value !== null && $value !== '') {
+ $href = $settings->url('data').'?resource='.urlencode($relation->relatedSlug)
+ .($relation->toMany
+ ? '&fk='.urlencode($relation->column).'&fv='.urlencode((string) $value)
+ : '&id='.urlencode((string) $value));
+ }
+ @endphp
+
None of these can be opened here: the entity on the other end is not exposed by
+ a repository this application declared, or the join runs through a pivot or a type column
+ that a single-column filter cannot express.
Read-only. Set firefly.admin.data.writable to allow edits and deletes.
+ Creating records is deliberately not offered: a generic form cannot honour an entity's constructor
+ invariants, and one that silently bypassed them would be worse than not having it.
Where this application's data lives, how it holds the connection open, and what
+ #[Transactional] compiled to. Connection settings come from Laravel's
+ config/database.php; secrets are masked with the same rule the actuator's
+ env endpoint uses.
+
+
+ @if (! $available)
+
+ @include('firefly-admin::_empty', [
+ 'title' => 'No database manager is bound',
+ 'body' => 'This application never resolved illuminate/database, which is a legal
+ LaraFly application — the container, the web layer and the actuator do not need one.
+ Install it and configure a connection to see anything here.',
+ ])
+
+ @else
+
+ {{-- Whether the default connection actually answers is the first thing anyone wants, so it leads. --}}
+ @isset($probe)
+
+ {{-- Said plainly rather than dressed up as a pool gauge: inventing a number here would be worse
+ than the absence it would be covering for. --}}
+
+ PHP has no connection pool. What exists is PDO's ATTR_PERSISTENT, which keeps a
+ connection open on the worker between requests — so under php-fpm the effective pool size is your
+ worker count, decided by the process manager rather than by this framework. Under Octane the
+ connection lives for the life of the worker either way. If you need real pooling in front of
+ Postgres, that is pgbouncer's job, and it sits between this application and the server.
+
+
+
+ {{-- THE WIZARD. A POST only: a GET must never be able to open an outbound socket to a host somebody
+ put in a URL, which keeps this surface out of reach of a link, an image tag or a prefetch. --}}
+ @if ($wizard->isAvailable())
+
+
+ Try a connection
+
+ nothing is written
+
+
+ @isset($trial)
+
+ The connection wizard is unavailable in production, and no configuration key changes that: a form
+ that opens a socket to a host you type is a request-forgery tool, and its error messages
+ distinguish “refused” from “timed out” well enough to map a private network.
+
+ @endif
+
+
+ @include('firefly-admin::_panel-head', [
+ 'title' => 'Transactional methods', 'count' => count($transactional),
+ 'filter' => 'tx-body', 'placeholder' => 'Filter methods…',
+ ])
+ @if ($transactional === [])
+ @include('firefly-admin::_empty', [
+ 'title' => 'Nothing is proxied',
+ 'body' => 'No method carries #[Transactional], or firefly:cache has
+ not run since one was added. The manifest is compiled, so a new annotation is
+ invisible until it is recompiled.',
+ ])
+ @else
+
Resolved firefly.* configuration as this process sees it. Keys that look secret are
+ masked by the endpoint before they reach this page.
+
+
+
+ @include('firefly-admin::_panel-head', [
+ 'title' => 'Configuration', 'count' => count($env),
+ 'filter' => 'env-body', 'placeholder' => 'Filter by key or value…',
+ ])
+ @if ($env === [])
+ @include('firefly-admin::_empty', [
+ 'title' => 'Nothing set',
+ 'body' => 'No firefly.* configuration is present. The skeleton ships a documented reference at config/firefly.php.',
+ ])
+ @else
+
+
+
Key
Value
+
+ @foreach ($env as $key => $value)
+
+
{{ $key }}
+
{{ $value }}
+
+ @endforeach
+
+
+
+ @endif
+
+@endsection
diff --git a/packages/admin/resources/views/graph.blade.php b/packages/admin/resources/views/graph.blade.php
new file mode 100644
index 0000000..a568cbb
--- /dev/null
+++ b/packages/admin/resources/views/graph.blade.php
@@ -0,0 +1,436 @@
+@extends('firefly-admin::layout')
+@section('title', 'Bean graph')
+@section('body')
+ @php
+ use Firefly\Admin\BeanGraph;
+ use Firefly\Admin\Format;
+
+ $counts = $graph->kindCounts();
+ $modules = $graph->modules();
+
+ // A stable colour per module, assigned by position so the same application always draws the same
+ // picture. Hues are spread around the wheel and kept away from the semantic red/green the rest of
+ // the dashboard reserves for status.
+ $hues = [212, 265, 28, 172, 320, 45, 190, 288, 96, 240, 12, 150];
+ $moduleHue = [];
+ foreach (array_values($modules) as $i => $module) {
+ $moduleHue[$module] = $hues[$i % count($hues)];
+ }
+
+ $byLevel = [];
+ foreach ($graph->nodes as $node) { $byLevel[$node['level']][] = $node; }
+ ksort($byLevel);
+
+ // Cluster same-module nodes within a layer so related things end up adjacent rather than scattered.
+ foreach ($byLevel as $level => $row) {
+ usort($row, fn (array $a, array $b): int => [BeanGraph::moduleOf($a['id']), $a['label']] <=> [BeanGraph::moduleOf($b['id']), $b['label']]);
+ $byLevel[$level] = $row;
+ }
+
+ // LAYOUT. A pure layered layout is wrong for this graph: dependency depth is shallow and wide, so
+ // most beans land on one or two levels and a stock skeleton produced a single row 54 nodes and
+ // 9184px across — which fit() then scaled to 11%, i.e. unreadable. Each LEVEL is therefore wrapped
+ // into a grid of its own, so the drawing stays a compact rectangle while arrows still read downward
+ // from dependents to dependencies.
+ $nodeW = 168; $nodeH = 40; $gapX = 14; $gapY = 20; $levelGap = 46;
+ $perRow = max(4, (int) ceil(sqrt(max(1, count($graph->nodes)))) + 2);
+
+ $at = [];
+ $canvasW = $perRow * ($nodeW + $gapX) - $gapX + 40;
+ $y = 20;
+
+ foreach ($byLevel as $row) {
+ $rows = array_chunk($row, $perRow);
+ foreach ($rows as $chunk) {
+ $rowW = count($chunk) * ($nodeW + $gapX) - $gapX;
+ $startX = ($canvasW - $rowW) / 2;
+ foreach (array_values($chunk) as $i => $node) {
+ $at[$node['id']] = ['x' => $startX + $i * ($nodeW + $gapX), 'y' => $y];
+ }
+ $y += $nodeH + $gapY;
+ }
+ $y += $levelGap - $gapY;
+ }
+
+ $canvasH = max(260, $y + 20);
+
+ @endphp
+
+
+
Bean graph
+
Every bean this application wired, and what each one depends on. A constructor asks for a
+ type, so an edge through an interface is drawn to the bean that implements it and
+ labelled with the interface.
The container has no cycle detection, so a
+ cycle among eager singletons exhausts memory at boot rather than reporting itself. Break one of
+ these edges — usually by depending on an interface and letting the other side provide it.
+
+ @endif
+
+
+
+
Wiring
+
+
+
+
+
+
+ @if ($graph->nodes === [])
+ @include('firefly-admin::_empty', [
+ 'title' => 'No beans to graph',
+ 'body' => 'Check firefly.scan.paths points at your application namespace.',
+ ])
+ @elseif (count($graph->nodes) > $settings->graphMaxNodes)
+ @include('firefly-admin::_empty', [
+ 'title' => 'Too many beans to draw at once',
+ 'body' => 'This application has '.count($graph->nodes).' beans. A diagram past '.$settings->graphMaxNodes.' nodes is a hairball rather than something you can read, so the relations are listed below instead. Raise firefly.admin.graph.max-nodes to draw it anyway.',
+ ])
+ @else
+
+ @foreach ($modules as $module)
+
+ @endforeach
+
+
+
+
+
+
Drag to pan · scroll to zoom · click a bean to focus it
+
+
+
+
+ @endif
+
+
+
+ @include('firefly-admin::_panel-head', [
+ 'title' => 'Relations', 'count' => count($graph->edges),
+ 'filter' => 'edges-body', 'placeholder' => 'Filter relations…',
+ ])
+ @if ($graph->edges === [])
+ @include('firefly-admin::_empty', [
+ 'title' => 'No relations found',
+ 'body' => 'Every bean here is built without depending on another. Constructor parameters typed as scalars are configuration, not wiring, and are deliberately not edges.',
+ ])
+ @else
+
These constructor types are satisfied by a
+ Laravel container binding rather than a scanned bean — the request, the config repository, a
+ database connection — so they are not drawn as nodes.
The most recent requests this application served, newest first. Bodies and headers are never
+ recorded — that is how these views leak credentials.
+
+
+
+ @include('firefly-admin::_panel-head', [
+ 'title' => 'Exchanges', 'count' => count($exchanges),
+ 'filter' => 'http-body', 'placeholder' => 'Filter by path, status or id…',
+ ])
+ @if ($exchanges === [])
+ @include('firefly-admin::_empty', [
+ 'title' => 'No exchanges recorded',
+ 'body' => 'Recording is off, or nothing has been served since this process started. Under PHP-FPM the buffer must be cache-backed to survive a request — see firefly.observability.httpexchanges.',
+ ])
+ @else
+
+@endsection
diff --git a/packages/admin/resources/views/layout.blade.php b/packages/admin/resources/views/layout.blade.php
new file mode 100644
index 0000000..9b0eeaa
--- /dev/null
+++ b/packages/admin/resources/views/layout.blade.php
@@ -0,0 +1,707 @@
+{{--
+ The dashboard shell.
+
+ Inline CSS and system fonts on purpose: a composer package cannot assume npm has run, and a dashboard
+ that needs a CDN at request time is useless in exactly the network-isolated environments where you most
+ want to look at one.
+
+ The chrome is deliberately NEUTRAL. Colour here means status — green is up, red is down, amber is
+ attention — so spending it on decoration would make a failing indicator compete with a heading for the
+ eye. The brand appears once, as the dot in the wordmark.
+--}}
+
+
+
+
+
+
+
+ @yield('title', 'Admin') · {{ $settings->title }}
+
+
+
+
+
+
+ {{--
+ Honesty about what the control does. LoggersEndpoint::setLevel() reaches into the Monolog handlers of
+ the CURRENT process, so under PHP-FPM the change lasts exactly as long as this request. Saying so is
+ better than letting someone believe they have changed production logging.
+ --}}
+
Applying a level calls the same endpoint POST /actuator/loggers/{name} does,
+ which mutates this PHP process only. Under PHP-FPM the next request is a different process and reverts to
+ the configured level — change logging.channels for anything that must persist.
+@endsection
diff --git a/packages/admin/resources/views/mappings.blade.php b/packages/admin/resources/views/mappings.blade.php
new file mode 100644
index 0000000..ed4d27b
--- /dev/null
+++ b/packages/admin/resources/views/mappings.blade.php
@@ -0,0 +1,41 @@
+@extends('firefly-admin::layout')
+@section('title', 'Routes')
+@section('body')
+ @php use Firefly\Admin\Format; @endphp
+
+
+
Routes
+
The compiled route table the dispatcher serves from, discovered from your
+ #[RestController] and #[Controller] classes.
+
+
+
+ @include('firefly-admin::_panel-head', [
+ 'title' => 'Mappings', 'count' => count($mappings),
+ 'filter' => 'map-body', 'placeholder' => 'Filter by path or handler…',
+ ])
+ @if ($mappings === [])
+ @include('firefly-admin::_empty', [
+ 'title' => 'No routes mapped',
+ 'body' => 'Create one with php artisan make:firefly-controller, then re-run firefly:cache if this application boots compiled.',
+ ])
+ @else
+
Counters, timers and gauges recorded through the meter registry. Units are inferred from the
+ meter name, the same convention the Prometheus exposition uses.
+
+
+
+ @include('firefly-admin::_panel-head', [
+ 'title' => 'Meters', 'count' => count($metrics),
+ 'filter' => 'metrics-body', 'placeholder' => 'Filter meters…',
+ ])
+ @if ($metrics === [])
+ @include('firefly-admin::_empty', [
+ 'title' => 'Nothing recorded yet',
+ 'body' => 'The default registry keeps meters in process memory, so under PHP-FPM a page only ever sees its own request. Set firefly.observability.metrics.store to a cache store to accumulate across workers.',
+ ])
+ @else
+
+
+
Meter
Statistic
Value
Relative
+
+ @foreach ($metrics as $metric)
+ @forelse ($metric['rows'] as $row)
+
+
{{ $loop->first ? $metric['name'] : '' }}
+
{{ $row['statistic'] }}
+
{{ $row['display'] }}
+
+ {{-- One shared scale across every meter: the bar answers "which of these is
+ large", which is the only comparison a mixed-unit list supports. --}}
+
+ @include('firefly-admin::_empty', [
+ 'title' => 'The dashboard has no page called “'.$slug.'”',
+ 'body' => 'Pick one from the menu on the left.',
+ ])
+
This process scanned its classes by reflection at startup — right while developing.
+ Run php artisan firefly:cache before deploying for a zero-reflection boot.
+ @endif
+
+
+
+ @include('firefly-admin::_panel-head', ['title' => 'Health indicators', 'count' => count($indicators)])
+ @if ($indicators === [])
+ @include('firefly-admin::_empty', [
+ 'title' => 'No indicators registered',
+ 'body' => 'Implement Firefly\Actuator\Health\HealthIndicator and register it as a bean to see it here.',
+ ])
+ @else
+
+ @include('firefly-admin::_empty', [
+ 'title' => 'The feature-switch console is switched off',
+ 'body' => 'It is off by default, unlike every other page here — the others describe the application
+ and this one changes it. Set firefly.admin.settings.enabled to see it, and
+ firefly.admin.settings.writable on top of that to get controls. Neither
+ does anything in production, where writes are refused whatever the configuration says.',
+ ])
+
The framework switches this application is running with, where each value came from, and — outside
+ production — a control to change it. A change is written to one file and merged over configuration
+ at boot; it is never written into .env.
+
+
+ @if (session('data-message'))
+
{{ session('data-message') }}
+ @endif
+
+ @if ($production)
+
+ Production. This console is read-only here, and no configuration key changes that.
+ A dashboard that can alter a running application is a remote-control surface; one reachable in
+ production is a vulnerability however carefully it is configured.
+
+ @elseif (! $writable)
+
+ Read-only. Set firefly.admin.settings.writable to get controls. It is a separate key
+ from enabled on purpose: seeing what is switched on should never imply being able to
+ switch it.
+
+ from says where the effective value came from: config means your
+ configuration set it, default means the framework's, and console means this
+ page overrode it. Only a fixed list of framework switches appears here — the console cannot express a
+ write to a key nobody put on that list, which is what keeps it a feature switch rather than a remote
+ configuration endpoint.
+
+ @include('firefly-admin::_empty', [
+ 'title' => 'This page has no endpoint to read',
+ 'body' => 'It renders the '.$page->requires.' actuator endpoint, which this process has not registered or has switched off. Check firefly.management.endpoint.'.$page->requires.'.enabled, and that the package providing it is installed.',
+ ])
+
+@endsection
diff --git a/packages/admin/src/AdminEndpointReader.php b/packages/admin/src/AdminEndpointReader.php
new file mode 100644
index 0000000..9640ad7
--- /dev/null
+++ b/packages/admin/src/AdminEndpointReader.php
@@ -0,0 +1,154 @@
+}>
+ */
+ public function healthIndicators(): array
+ {
+ if ($this->container === null || ! $this->container->bound(HealthContributorRegistry::class)) {
+ return [];
+ }
+
+ try {
+ $registry = $this->container->get(HealthContributorRegistry::class);
+ } catch (Throwable) {
+ return [];
+ }
+
+ $indicators = [];
+ foreach ($registry->all() as $name => $indicator) {
+ try {
+ $health = $indicator->health();
+ $indicators[] = [
+ 'name' => $name,
+ 'status' => $health->status->value,
+ 'details' => $health->details,
+ ];
+ } catch (Throwable $e) {
+ $indicators[] = [
+ 'name' => $name,
+ 'status' => 'DOWN',
+ 'details' => ['error' => $e::class, 'message' => $e->getMessage()],
+ ];
+ }
+ }
+
+ return $indicators;
+ }
+
+ /**
+ * The endpoint ids that are registered AND not switched off, in registration order.
+ *
+ * @return list
+ */
+ public function available(): array
+ {
+ $ids = [];
+ foreach ($this->registry->all() as $id => $endpoint) {
+ if ($endpoint->enabled() && $this->config->bool("firefly.management.endpoint.{$id}.enabled", true)) {
+ $ids[] = $id;
+ }
+ }
+
+ return $ids;
+ }
+
+ public function has(string $id): bool
+ {
+ return in_array($id, $this->available(), true);
+ }
+
+ /**
+ * The endpoint's payload as an array, or null when it is absent, switched off, returned no body, or
+ * threw.
+ *
+ * A throwing endpoint must not take the page down with it: one broken health indicator should degrade
+ * that panel, not the dashboard. The failure is surfaced to the caller as null so the view can say so.
+ *
+ * @param list $subPath
+ * @param array $query
+ * @return array|null
+ */
+ public function read(string $id, array $subPath = [], array $query = []): ?array
+ {
+ $endpoint = $this->registry->get($id);
+ if ($endpoint === null || ! $this->has($id)) {
+ return null;
+ }
+
+ try {
+ $response = $endpoint->handle(new EndpointRequest('GET', $subPath, $query));
+ } catch (Throwable) {
+ return null;
+ }
+
+ return $response === null || is_string($response->body) ? null : $response->body;
+ }
+
+ /**
+ * POST to an endpoint — the loggers endpoint's level mutation is the only current caller.
+ *
+ * @param list $subPath
+ * @param array $body
+ */
+ public function write(string $id, array $subPath, array $body): bool
+ {
+ $endpoint = $this->registry->get($id);
+ if ($endpoint === null || ! $this->has($id)) {
+ return false;
+ }
+
+ try {
+ $response = $endpoint->handle(new EndpointRequest('POST', $subPath, [], $body));
+ } catch (Throwable) {
+ return false;
+ }
+
+ return $response !== null && $response->status >= 200 && $response->status < 300;
+ }
+}
diff --git a/packages/admin/src/AdminServiceProvider.php b/packages/admin/src/AdminServiceProvider.php
new file mode 100644
index 0000000..46e2ca0
--- /dev/null
+++ b/packages/admin/src/AdminServiceProvider.php
@@ -0,0 +1,77 @@
+loadViewsFrom(__DIR__.'/../resources/views', 'firefly-admin');
+
+ $this->registerSettingsConsole();
+
+ parent::register();
+ }
+
+ /**
+ * The feature-switch console, and its overrides applied.
+ *
+ * IT HAPPENS IN register(), WHICH IS THE POINT. Every settings object in the framework is built ONCE
+ * from configuration and held for the process — OpenApiProperties, AdminSettings, the actuator's
+ * exposure model — so an override merged after the first of them is read is a value this page reports
+ * and the application does not use. That was not a hypothesis: applying it from the dashboard's own boot
+ * pass wrote the file, showed the new state on the page, and left /openapi.json answering 200 with the
+ * switch reading "off". register() runs before any boot pass and before any bean resolves, which is the
+ * only place the merge is true.
+ */
+ private function registerSettingsConsole(): void
+ {
+ $this->app->singleton(SettingsConsole::class, function (): SettingsConsole {
+ /** @var ConfigRepository $repository */
+ $repository = $this->app->make('config');
+ $config = new Config($repository);
+
+ return new SettingsConsole(
+ $config,
+ $repository,
+ SettingsSettings::fromConfig($config),
+ // bootstrapPath() is on the Application contract, and $this->app is typed as one — the
+ // instanceof would always be true and PHPStan says so.
+ (string) $this->app->bootstrapPath('cache'),
+ );
+ });
+
+ $console = $this->app->make(SettingsConsole::class);
+
+ if ($console->isEnabled()) {
+ $console->apply();
+ }
+ }
+
+ /**
+ * @return list
+ */
+ public function passes(): array
+ {
+ return [new AdminRouteRegistrar];
+ }
+}
diff --git a/packages/admin/src/AdminSettings.php b/packages/admin/src/AdminSettings.php
new file mode 100644
index 0000000..c3b9f1c
--- /dev/null
+++ b/packages/admin/src/AdminSettings.php
@@ -0,0 +1,94 @@
+ $excludedPages page slugs hidden from the menu and refused by the router
+ */
+ public function __construct(
+ public bool $enabled,
+ public string $basePath,
+ public string $title,
+ public int $refreshSeconds = 10,
+ public string $theme = 'auto',
+ public int $graphMaxNodes = 220,
+ public array $excludedPages = [],
+ ) {}
+
+ public static function fromConfig(Config $config): self
+ {
+ $base = trim($config->string('firefly.admin.base-path', '/firefly'), '/');
+
+ return new self(
+ enabled: $config->bool('firefly.admin.enabled', $config->bool('app.debug', false)),
+ basePath: $base === '' ? 'firefly' : $base,
+ title: $config->string('firefly.admin.title', $config->string('app.name', 'LaraFly')),
+ // Floored at 2s: a shorter interval reloads faster than a page renders, so the countdown would
+ // never finish and the dashboard would hammer the application it is supposed to be observing.
+ refreshSeconds: max(2, $config->int('firefly.admin.refresh-seconds', 10)),
+ theme: self::theme($config->string('firefly.admin.theme', 'auto')),
+ // Past this, a dependency diagram is a hairball rather than something anyone can read, so the
+ // graph page lists the relations instead of drawing them. Configurable because "unreadable"
+ // depends on the screen and the application.
+ graphMaxNodes: max(0, $config->int('firefly.admin.graph.max-nodes', 220)),
+ excludedPages: self::csv($config->string('firefly.admin.pages.exclude', '')),
+ );
+ }
+
+ /** An unrecognised theme falls back to following the operating system rather than rendering unstyled. */
+ private static function theme(string $configured): string
+ {
+ $theme = strtolower(trim($configured));
+
+ return in_array($theme, ['auto', 'light', 'dark'], true) ? $theme : 'auto';
+ }
+
+ /**
+ * Whether a page may be reached at all.
+ *
+ * `firefly.admin.pages.exclude` is a hard refusal, not a menu preference: the page is hidden AND its URL
+ * 404s. A deployment that hides `env` because it is uncomfortable having resolved configuration one
+ * click away has not achieved anything if the URL still answers.
+ */
+ public function allows(string $slug): bool
+ {
+ return ! in_array($slug === '' ? 'overview' : $slug, $this->excludedPages, true);
+ }
+
+ /**
+ * @return list
+ */
+ private static function csv(string $value): array
+ {
+ return array_values(array_filter(
+ array_map(static fn (string $part): string => strtolower(trim($part)), explode(',', $value)),
+ static fn (string $part): bool => $part !== '',
+ ));
+ }
+
+ /** An absolute path for a dashboard page, e.g. url('beans') => /firefly/beans. */
+ public function url(string $page = ''): string
+ {
+ return '/'.$this->basePath.($page === '' ? '' : '/'.$page);
+ }
+}
diff --git a/packages/admin/src/BeanGraph.php b/packages/admin/src/BeanGraph.php
new file mode 100644
index 0000000..ec45faa
--- /dev/null
+++ b/packages/admin/src/BeanGraph.php
@@ -0,0 +1,223 @@
+ $nodes
+ * @param list $edges
+ * @param list $cycles
+ * @param list $unresolved
+ */
+ public function __construct(
+ public readonly array $nodes,
+ public readonly array $edges,
+ public readonly array $cycles,
+ public readonly array $unresolved,
+ ) {}
+
+ /**
+ * @param array $beans rows as BeansCatalog publishes them
+ * @param array $configProperties rows as the configprops endpoint publishes them, keyed by class
+ */
+ public static function build(array $beans, array $configProperties = []): self
+ {
+ $index = new BeanGraphIndex;
+
+ foreach ($beans as $row) {
+ if (! is_array($row) || ! is_string($row['class'] ?? null)) {
+ continue;
+ }
+ $index->addComponent($row);
+ }
+
+ foreach ($configProperties as $class => $row) {
+ if (is_array($row) && is_string($row['class'] ?? $class)) {
+ $index->addConfigProperties(is_string($row['class'] ?? null) ? $row['class'] : (string) $class);
+ }
+ }
+
+ [$edges, $unresolved] = $index->edges();
+ [$levels, $cycles] = self::levels($index->ids(), $edges);
+
+ $degree = [];
+ foreach ($edges as $edge) {
+ $degree[$edge['from']]['out'] = ($degree[$edge['from']]['out'] ?? 0) + 1;
+ $degree[$edge['to']]['in'] = ($degree[$edge['to']]['in'] ?? 0) + 1;
+ }
+
+ $nodes = [];
+ foreach ($index->nodes() as $id => $node) {
+ $nodes[] = [
+ ...$node,
+ 'level' => $levels[$id] ?? 0,
+ 'in' => $degree[$id]['in'] ?? 0,
+ 'out' => $degree[$id]['out'] ?? 0,
+ ];
+ }
+
+ usort($nodes, static fn (array $a, array $b): int => [$a['level'], $a['label']] <=> [$b['level'], $b['label']]);
+
+ return new self($nodes, $edges, $cycles, $unresolved);
+ }
+
+ /**
+ * Kept for the older two-argument shape.
+ *
+ * @param array $beans
+ */
+ public static function fromCatalog(array $beans): self
+ {
+ return self::build($beans);
+ }
+
+ /** @return array node count per kind, for the page's summary */
+ public function kindCounts(): array
+ {
+ $counts = [self::KIND_COMPONENT => 0, self::KIND_BEAN => 0, self::KIND_CONFIG => 0];
+ foreach ($this->nodes as $node) {
+ $counts[$node['kind']] = ($counts[$node['kind']] ?? 0) + 1;
+ }
+
+ return $counts;
+ }
+
+ /**
+ * The namespace roots present, most-populated first — the drawing colours by module, and a legend has to
+ * name them.
+ *
+ * @return list
+ */
+ public function modules(): array
+ {
+ $counts = [];
+ foreach ($this->nodes as $node) {
+ $module = self::moduleOf($node['id']);
+ $counts[$module] = ($counts[$module] ?? 0) + 1;
+ }
+
+ arsort($counts);
+
+ return array_keys($counts);
+ }
+
+ /** The first two namespace segments — `Firefly\Observability`, `App\Http` — which is how a reader groups. */
+ public static function moduleOf(string $id): string
+ {
+ $parts = explode('\\', ltrim($id, '\\'));
+
+ return match (true) {
+ count($parts) <= 1 => '(global)',
+ count($parts) === 2 => $parts[0],
+ default => $parts[0].'\\'.$parts[1],
+ };
+ }
+
+ /**
+ * Longest-path layering, so a node always sits below everything that depends on it. Depth is memoised and
+ * the walk carries a visited set, so a cycle terminates instead of recursing forever — and the edge that
+ * closed it is reported.
+ *
+ * @param list $ids
+ * @param list $edges
+ * @return array{0: array, 1: list}
+ */
+ private static function levels(array $ids, array $edges): array
+ {
+ $out = [];
+ foreach ($edges as $edge) {
+ $out[$edge['from']][] = $edge['to'];
+ }
+
+ $depth = [];
+ $cycles = [];
+
+ $walk = static function (string $node, array $path) use (&$walk, &$depth, &$cycles, $out): int {
+ if (isset($depth[$node])) {
+ return $depth[$node];
+ }
+ if (isset($path[$node])) {
+ return 0;
+ }
+
+ $path[$node] = true;
+ $deepest = 0;
+ foreach ($out[$node] ?? [] as $next) {
+ if (isset($path[$next])) {
+ $cycles[] = ['from' => $node, 'to' => $next];
+
+ continue;
+ }
+ $deepest = max($deepest, $walk($next, $path) + 1);
+ }
+
+ return $depth[$node] = $deepest;
+ };
+
+ foreach ($ids as $id) {
+ $walk($id, []);
+ }
+
+ // Depth counts how far a node's longest chain of dependencies runs; the drawing wants the opposite,
+ // with dependents on top. Flip it so level 0 is what nothing depends on.
+ $max = $depth === [] ? 0 : max($depth);
+ $levels = [];
+ foreach ($depth as $id => $value) {
+ $levels[$id] = $max - $value;
+ }
+
+ $seen = [];
+ $unique = [];
+ foreach ($cycles as $cycle) {
+ $key = $cycle['from'].'>'.$cycle['to'];
+ if (! isset($seen[$key])) {
+ $seen[$key] = true;
+ $unique[] = $cycle;
+ }
+ }
+
+ return [$levels, $unique];
+ }
+}
diff --git a/packages/admin/src/BeanGraphIndex.php b/packages/admin/src/BeanGraphIndex.php
new file mode 100644
index 0000000..e43f11e
--- /dev/null
+++ b/packages/admin/src/BeanGraphIndex.php
@@ -0,0 +1,232 @@
+ */
+ private array $nodes = [];
+
+ /** @var array interface or produced type => the node id that satisfies it */
+ private array $satisfiedBy = [];
+
+ /** @var list, type: string}> */
+ private array $pending = [];
+
+ /** @var array how many factory methods produce each type */
+ private array $producerCount = [];
+
+ /**
+ * @param array $row
+ */
+ public function addComponent(array $row): void
+ {
+ /** @var string $class */
+ $class = $row['class'];
+
+ $this->put($class, [
+ 'id' => $class,
+ 'label' => Format::shortClass($class),
+ 'namespace' => rtrim(Format::namespaceOf($class), '\\'),
+ 'kind' => BeanGraph::KIND_COMPONENT,
+ 'stereotype' => is_string($row['stereotype'] ?? null) ? $row['stereotype'] : '',
+ 'scope' => is_string($row['scope'] ?? null) ? $row['scope'] : '',
+ 'detail' => '',
+ ]);
+
+ foreach ($this->strings($row['interfaces'] ?? null) as $interface) {
+ $this->satisfy($interface, $class);
+ }
+
+ $this->pending[] = [
+ 'from' => $class,
+ 'dependencies' => $this->strings($row['dependencies'] ?? null),
+ 'type' => BeanGraph::EDGE_INJECTS,
+ ];
+
+ foreach ($this->producers($row['produces'] ?? null) as $produced) {
+ $this->producerCount[$produced['type']] = ($this->producerCount[$produced['type']] ?? 0) + 1;
+ }
+
+ foreach ($this->producers($row['produces'] ?? null) as $produced) {
+ $this->addBean($class, $produced);
+ }
+ }
+
+ public function addConfigProperties(string $class): void
+ {
+ // A #[ConfigProperties] DTO is bound and injectable but is neither scanned as a component nor
+ // produced by a factory, so nothing else here would ever create a node for it — which is why
+ // `App\GreetingProperties` showed up as an unresolved dependency of GreetingService rather than as
+ // the bean it is.
+ $this->put($class, [
+ 'id' => $class,
+ 'label' => Format::shortClass($class),
+ 'namespace' => rtrim(Format::namespaceOf($class), '\\'),
+ 'kind' => BeanGraph::KIND_CONFIG,
+ 'stereotype' => 'config-properties',
+ 'scope' => 'Singleton',
+ 'detail' => 'bound from configuration',
+ ]);
+
+ $this->satisfy($class, $class);
+ }
+
+ /**
+ * @param array{type: string, method: string, dependencies: list} $produced
+ */
+ private function addBean(string $declaring, array $produced): void
+ {
+ $contested = ($this->producerCount[$produced['type']] ?? 0) > 1;
+ $id = $contested ? $declaring.'::'.$produced['method'].'()' : $produced['type'];
+
+ $this->put($id, [
+ 'id' => $id,
+ 'label' => Format::shortClass($produced['type']),
+ 'namespace' => rtrim(Format::namespaceOf($produced['type']), '\\'),
+ 'kind' => BeanGraph::KIND_BEAN,
+ 'stereotype' => 'bean',
+ 'scope' => 'Singleton',
+ 'detail' => Format::shortClass($declaring).'::'.$produced['method'].'()',
+ ]);
+
+ // The produced type resolves to this node. With competitors, first-writer-wins gives the bare type a
+ // stable owner while each competitor keeps its own node — the same shape the container itself has,
+ // where the type key aliases the #[Primary] winner and every candidate stays reachable by name.
+ $this->satisfy($produced['type'], $id);
+
+ $this->pending[] = ['from' => $declaring, 'dependencies' => [$id], 'type' => BeanGraph::EDGE_PRODUCES];
+ $this->pending[] = ['from' => $id, 'dependencies' => $produced['dependencies'], 'type' => BeanGraph::EDGE_INJECTS];
+ }
+
+ /** @return list */
+ public function ids(): array
+ {
+ return array_keys($this->nodes);
+ }
+
+ /** @return array */
+ public function nodes(): array
+ {
+ return $this->nodes;
+ }
+
+ /**
+ * Every declared dependency resolved onto the node set, plus the types nothing here provides.
+ *
+ * An unresolved type is reported rather than dropped: it is almost always a Laravel container binding
+ * (the Request, the config repository, a database connection) rather than a bean, and "why is my bean
+ * not in the graph" is exactly the question this page exists to answer.
+ *
+ * @return array{0: list, 1: list}
+ */
+ public function edges(): array
+ {
+ $edges = [];
+ $seen = [];
+ $unresolved = [];
+
+ foreach ($this->pending as $entry) {
+ foreach ($entry['dependencies'] as $dependency) {
+ $target = $this->resolve($dependency);
+
+ if ($target === null) {
+ $unresolved[] = $dependency;
+
+ continue;
+ }
+
+ if ($target === $entry['from']) {
+ continue;
+ }
+
+ $key = $entry['from'].'>'.$target.'>'.$entry['type'];
+ if (isset($seen[$key])) {
+ continue;
+ }
+ $seen[$key] = true;
+
+ $edges[] = [
+ 'from' => $entry['from'],
+ 'to' => $target,
+ 'via' => $target === $dependency ? null : $dependency,
+ 'type' => $entry['type'],
+ ];
+ }
+ }
+
+ return [$edges, array_values(array_unique($unresolved))];
+ }
+
+ private function resolve(string $type): ?string
+ {
+ return isset($this->nodes[$type]) ? $type : ($this->satisfiedBy[$type] ?? null);
+ }
+
+ /** First writer wins, so the same application always draws the same graph. */
+ private function satisfy(string $type, string $nodeId): void
+ {
+ $this->satisfiedBy[$type] ??= $nodeId;
+ }
+
+ /**
+ * @param array{id: string, label: string, namespace: string, kind: string, stereotype: string, scope: string, detail: string} $node
+ */
+ private function put(string $id, array $node): void
+ {
+ $this->nodes[$id] ??= $node;
+ }
+
+ /**
+ * @return list}>
+ */
+ private function producers(mixed $produces): array
+ {
+ if (! is_array($produces)) {
+ return [];
+ }
+
+ $out = [];
+ foreach ($produces as $entry) {
+ if (! is_array($entry) || ! is_string($entry['type'] ?? null) || $entry['type'] === '') {
+ continue;
+ }
+
+ $out[] = [
+ 'type' => $entry['type'],
+ 'method' => is_string($entry['method'] ?? null) ? $entry['method'] : 'bean',
+ 'dependencies' => $this->strings($entry['dependencies'] ?? null),
+ ];
+ }
+
+ return $out;
+ }
+
+ /** @return list */
+ private function strings(mixed $value): array
+ {
+ if (! is_array($value)) {
+ return [];
+ }
+
+ return array_values(array_filter(
+ $value,
+ static fn (mixed $item): bool => is_string($item) && $item !== '',
+ ));
+ }
+}
diff --git a/packages/admin/src/Boot/AdminRouteRegistrar.php b/packages/admin/src/Boot/AdminRouteRegistrar.php
new file mode 100644
index 0000000..6f6744d
--- /dev/null
+++ b/packages/admin/src/Boot/AdminRouteRegistrar.php
@@ -0,0 +1,164 @@
+config);
+ if (! $settings->enabled) {
+ return;
+ }
+
+ $container = $context->container;
+
+ // Blade is required to render the dashboard and is NOT a dependency of this package — a JSON-only
+ // deployment has no view factory. Mounting routes that would fatal on first request is worse than
+ // mounting none, so back off silently and leave the JSON actuator as the management surface.
+ if (! $container->bound('view')) {
+ return;
+ }
+
+ $container->instance(AdminSettings::class, $settings);
+
+ $container->singleton(AdminEndpointReader::class, static fn (): AdminEndpointReader => new AdminEndpointReader(
+ $container->make(ActuatorRegistry::class),
+ $context->config,
+ $container,
+ ));
+ // The data browser is assembled here rather than declared as beans because it must exist even when
+ // it is switched OFF: the dashboard asks it whether it is enabled, and a page that cannot ask has to
+ // guess. Its own settings answer false by default, so building it costs a few objects and grants
+ // nothing.
+ // Assembled here for the same reason DataBrowser is: it must exist even when there is no database
+ // manager to describe, because the page's job in that case is to say so.
+ $container->singleton(DatasourceReport::class, static fn (): DatasourceReport => DatasourceReport::forContainer($container));
+ $container->singleton(ConnectionWizard::class, static fn (): ConnectionWizard => ConnectionWizard::forContainer($container, $context->config));
+
+ $container->singleton(DataBrowser::class, static function () use ($container, $context): DataBrowser {
+ $settings = DataBrowserSettings::fromConfig($context->config);
+ $introspector = new RepositoryIntrospector;
+
+ return new DataBrowser(
+ $settings,
+ new DataResourceRegistry(
+ // Null when the actuator has not populated a catalogue — the registry treats that as
+ // "nothing discoverable" rather than failing the page.
+ $container->bound(BeansCatalog::class) ? $container->make(BeansCatalog::class) : null,
+ $introspector,
+ $settings,
+ ),
+ new DataSchemaFactory($introspector),
+ new DataQueryEngine($introspector),
+ $container,
+ );
+ });
+
+ $container->singleton(AdminAction::class, static fn (): AdminAction => new AdminAction(
+ $container->make(AdminSettings::class),
+ $container->make(AdminEndpointReader::class),
+ $container->make(ViewFactory::class),
+ $container,
+ // Resolved here rather than injected as a bean so the dashboard works whether or not the
+ // actuator's own wiring has bound one: the settings come from the same config keys either way.
+ new ManagementPortGuard(ManagementServerSettings::fromConfig($context->config)),
+ $container->make(DataBrowser::class),
+ $container->make(DatasourceReport::class),
+ $container->make(SettingsConsole::class),
+ $container->make(ConnectionWizard::class),
+ ));
+
+ /** @var Router $router */
+ $router = $container->make('router');
+ $base = $settings->basePath;
+
+ // THE `web` GROUP, AND WHY IT IS NOT OPTIONAL. These routes were mounted bare, and a bare route in
+ // Laravel carries NO middleware at all — no session, and no VerifyCsrfToken. Every `@csrf` in these
+ // views was therefore decorative: a tokenless POST to /firefly/loggers was accepted and changed the
+ // log level, and the same held for every write the data browser and the settings console added. A
+ // form that renders a CSRF field while the route ignores it is worse than one that renders none,
+ // because it looks protected.
+ //
+ // `web` is also what makes the rest of the page work: the session it starts is what carries the
+ // outcome sentence a write flashes on its way back, which is why AdminAction::redirect() had to
+ // guard on the session not being started at all.
+ //
+ // THE CLASSES, NOT THE `web` GROUP NAME, and that distinction is the fix working versus only
+ // appearing to. Naming the group and guarding on `hasMiddlewareGroup('web')` looked right and
+ // attached NOTHING: this pass runs inside the framework's boot pipeline, before the application's
+ // RouteServiceProvider has defined that group, so the guard was false at registration time and
+ // silently produced an empty list. Referring to the classes needs no group and no ordering
+ // assumption, and each is skipped if the installation does not have it.
+ $middleware = array_values(array_filter([
+ EncryptCookies::class,
+ AddQueuedCookiesToResponse::class,
+ StartSession::class,
+ ShareErrorsFromSession::class,
+ // Laravel renamed this in 11; both spellings are accepted so the dashboard is not pinned to one
+ // minor version for its only line of CSRF defence.
+ class_exists(ValidateCsrfToken::class) ? ValidateCsrfToken::class : VerifyCsrfToken::class,
+ ], static fn (string $class): bool => class_exists($class)));
+
+ $router->get($base, static fn (Request $request) => $container->make(AdminAction::class)($request))
+ ->middleware($middleware)
+ ->name('firefly.admin.index');
+ $router->match(['GET', 'POST'], $base.'/{page}', static fn (Request $request, string $page) => $container->make(AdminAction::class)($request, $page))
+ ->middleware($middleware)
+ ->where('page', '[A-Za-z0-9\-_/]*')
+ ->name('firefly.admin.page');
+ }
+}
diff --git a/packages/admin/src/Data/ConnectionWizard.php b/packages/admin/src/Data/ConnectionWizard.php
new file mode 100644
index 0000000..fbf8624
--- /dev/null
+++ b/packages/admin/src/Data/ConnectionWizard.php
@@ -0,0 +1,229 @@
+ 3306, 'mariadb' => 3306, 'pgsql' => 5432, 'sqlsrv' => 1433, 'sqlite' => 0];
+
+ public function __construct(
+ private readonly ?ConnectionFactory $factory,
+ private readonly bool $enabled,
+ private readonly bool $production,
+ ) {}
+
+ public static function forContainer(Container $container, Config $config): self
+ {
+ $factory = null;
+ try {
+ $factory = $container->make(ConnectionFactory::class);
+ } catch (Throwable) {
+ }
+
+ $environment = strtolower($config->string('app.env', 'production'));
+
+ return new self(
+ $factory,
+ $config->bool('firefly.admin.datasource.wizard', false),
+ in_array($environment, ['production', 'prod'], true),
+ );
+ }
+
+ public function isAvailable(): bool
+ {
+ return $this->enabled && ! $this->production && $this->factory !== null;
+ }
+
+ public function isProduction(): bool
+ {
+ return $this->production;
+ }
+
+ /** @return list */
+ public function drivers(): array
+ {
+ return array_keys(self::DRIVERS);
+ }
+
+ public function defaultPort(string $driver): int
+ {
+ return self::DRIVERS[$driver] ?? 0;
+ }
+
+ /**
+ * Open the connection described by $input and report what happened.
+ *
+ * @param array $input
+ * @return array{ok: bool, message: string, version: string, snippet: string}
+ */
+ public function test(array $input): array
+ {
+ if (! $this->isAvailable() || $this->factory === null) {
+ return [
+ 'ok' => false,
+ 'message' => $this->production
+ ? 'Refused: the wizard is unavailable in production, and no configuration key changes that.'
+ : 'Refused: set firefly.admin.datasource.wizard to use it.',
+ 'version' => '',
+ 'snippet' => '',
+ ];
+ }
+
+ $settings = $this->normalise($input);
+
+ if (! in_array($settings['driver'], $this->drivers(), true)) {
+ return ['ok' => false, 'message' => 'That is not a driver this wizard knows.', 'version' => '', 'snippet' => ''];
+ }
+
+ try {
+ $connection = $this->factory->make($settings);
+
+ // getPdo() FIRST, and that ordering is the whole difference between a useful failure and a
+ // useless one. Going through selectOne() puts Laravel's reconnect wrapper in the way, which
+ // catches the driver's exception and rethrows "Lost connection and no reconnector available" —
+ // the same sentence for a wrong password, a closed port and a typo in the host. Forcing the
+ // connection open directly lets the driver's own message through.
+ $pdo = $connection->getPdo();
+ $attribute = $pdo->getAttribute(PDO::ATTR_SERVER_VERSION);
+ $version = is_scalar($attribute) ? (string) $attribute : '';
+
+ // And a real statement after it, because a PDO handle proves the socket opened and the
+ // credentials were accepted — not that the DATABASE named exists and is readable.
+ $connection->selectOne('select 1');
+ $connection->disconnect();
+
+ return [
+ 'ok' => true,
+ 'message' => 'The connection opened and answered a query.',
+ 'version' => $version,
+ 'snippet' => $this->snippet($settings),
+ ];
+ } catch (Throwable $e) {
+ // The driver's own message, verbatim, and the deepest one in the chain: "could not connect" is
+ // the least useful thing to say here, and the whole point is that `password authentication
+ // failed for user "app"` and `no such host` send you to different places.
+ return ['ok' => false, 'message' => $this->deepest($e), 'version' => '', 'snippet' => ''];
+ }
+ }
+
+ /**
+ * The innermost message in an exception chain.
+ *
+ * Laravel wraps a connection failure at least once and sometimes twice, and every wrapper's message is
+ * less specific than the one it wrapped. The driver sits at the bottom.
+ */
+ private function deepest(Throwable $e): string
+ {
+ while ($e->getPrevious() !== null) {
+ $e = $e->getPrevious();
+ }
+
+ return $e->getMessage();
+ }
+
+ /**
+ * @param array $input
+ * @return array
+ */
+ private function normalise(array $input): array
+ {
+ $driver = strtolower(trim($input['driver'] ?? 'mysql'));
+ $get = static fn (string $key, string $fallback = ''): string => trim($input[$key] ?? '') !== '' ? trim($input[$key]) : $fallback;
+
+ if ($driver === 'sqlite') {
+ // ONLY `:memory:`. Every other sqlite "database" is a PATH, and PDO CREATES it — so a form field
+ // that reached the driver was a write primitive: `database=/var/www/html/x.php` (or a `file:`
+ // URI with `?mode=rwc`) puts an attacker-named, attacker-located file on disk, which is a long
+ // way from "test a connection" and flatly contradicts this class's promise to write nothing.
+ // Testing a sqlite connection has no host and no credentials to get wrong, so there is nothing
+ // the path would teach that :memory: does not.
+ return ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '', 'foreign_key_constraints' => true];
+ }
+
+ return [
+ 'driver' => $driver,
+ 'host' => $get('host', '127.0.0.1'),
+ 'port' => (int) $get('port', (string) $this->defaultPort($driver)),
+ 'database' => $get('database'),
+ 'username' => $get('username'),
+ 'password' => $input['password'] ?? '',
+ 'charset' => $get('charset', $driver === 'pgsql' ? 'utf8' : 'utf8mb4'),
+ 'prefix' => '',
+ // A wizard that hung for the driver's default timeout — thirty seconds on some, none at all on
+ // others — would look broken on exactly the wrong host.
+ 'options' => [PDO::ATTR_TIMEOUT => 5],
+ ];
+ }
+
+ /**
+ * The `config/database.php` block for settings that worked — with the password as an `env()` call, never
+ * inlined. A wizard that printed a working credential into a file people paste into a repository would
+ * be a very effective way of leaking one.
+ *
+ * @param array $settings
+ */
+ private function snippet(array $settings): string
+ {
+ $string = static fn (string $key): string => is_scalar($settings[$key] ?? null) ? (string) $settings[$key] : '';
+
+ if ($string('driver') === 'sqlite') {
+ return "'sqlite' => [\n"
+ ." 'driver' => 'sqlite',\n"
+ ." 'database' => env('DB_DATABASE', database_path('database.sqlite')),\n"
+ ." 'prefix' => '',\n"
+ .'],';
+ }
+
+ return sprintf(
+ "'%s' => [\n"
+ ." 'driver' => '%s',\n"
+ ." 'host' => env('DB_HOST', '%s'),\n"
+ ." 'port' => env('DB_PORT', '%s'),\n"
+ ." 'database' => env('DB_DATABASE', '%s'),\n"
+ ." 'username' => env('DB_USERNAME', '%s'),\n"
+ ." 'password' => env('DB_PASSWORD', ''),\n"
+ ." 'charset' => '%s',\n"
+ ." 'prefix' => '',\n"
+ .'],',
+ $string('driver'),
+ $string('driver'),
+ $string('host'),
+ $string('port'),
+ $string('database'),
+ $string('username'),
+ $string('charset'),
+ );
+ }
+}
diff --git a/packages/admin/src/Data/DataBrowser.php b/packages/admin/src/Data/DataBrowser.php
new file mode 100644
index 0000000..5942ffc
--- /dev/null
+++ b/packages/admin/src/Data/DataBrowser.php
@@ -0,0 +1,677 @@
+bound(BeansCatalog::class)) {
+ try {
+ $catalog = $container->make(BeansCatalog::class);
+ } catch (Throwable) {
+ $catalog = null;
+ }
+ }
+
+ return new self(
+ $settings,
+ new DataResourceRegistry($catalog, $introspector, $settings),
+ new DataSchemaFactory($introspector),
+ new DataQueryEngine($introspector),
+ $container,
+ );
+ }
+
+ public function settings(): DataBrowserSettings
+ {
+ return $this->settings;
+ }
+
+ public function isEnabled(): bool
+ {
+ return $this->settings->enabled;
+ }
+
+ /** True only when BOTH gates are open — the browser is on and writes are permitted. */
+ public function isWritable(): bool
+ {
+ return $this->settings->canWrite();
+ }
+
+ /**
+ * Every browsable resource, or an empty list when the browser is switched off.
+ *
+ * @return list
+ */
+ public function resources(): array
+ {
+ return $this->settings->enabled ? $this->registry->all() : [];
+ }
+
+ public function resource(string $slug): ?DataResource
+ {
+ return $this->settings->enabled ? $this->registry->get($slug) : null;
+ }
+
+ public function schema(string $slug): ?DataSchema
+ {
+ $resource = $this->resource($slug);
+
+ return $resource === null ? null : $this->schemas->for($resource);
+ }
+
+ /**
+ * One page of a resource.
+ *
+ * `$perPage` is null to mean "the configured default" and is clamped to `firefly.admin.data.max-page-size`
+ * in every case, so a caller-supplied page size can never ask the fallback path to materialise a table.
+ * `$page` is 1-based and floored at 1.
+ *
+ * @param list $filters
+ */
+ public function list(
+ string $slug,
+ int $page = 1,
+ ?int $perPage = null,
+ ?string $sort = null,
+ string $direction = 'asc',
+ ?string $search = null,
+ array $filters = [],
+ ): DataListing {
+ $perPage = $this->settings->clampPageSize($perPage);
+ $page = max(1, $page);
+
+ if (! $this->settings->enabled) {
+ return DataListing::failure(self::DISABLED, null, null, $page, $perPage);
+ }
+
+ $resource = $this->registry->get($slug);
+ if ($resource === null) {
+ return DataListing::failure('No such resource.', null, null, $page, $perPage);
+ }
+
+ $schema = $this->schemas->for($resource);
+ $repository = $this->repositoryFor($resource);
+ if ($repository === null) {
+ return DataListing::failure(self::UNRESOLVABLE, $resource, $schema, $page, $perPage);
+ }
+
+ return $this->engine->list($repository, $resource, $schema, $page, $perPage, $sort, $direction, $search, $this->validFilters($filters, $schema));
+ }
+
+ /**
+ * Filters the resource can actually answer, with everything else dropped.
+ *
+ * A filter naming a column the resource does not PUBLISH FOR FILTERING is dropped rather than passed to
+ * the database, and so is one naming an operator that is not in the fixed set. Both arrive in a URL an
+ * operator can hand-edit, and a query that reached the driver with an arbitrary identifier or comparison
+ * in it is a column-name oracle at best. Dropping rather than erroring is deliberate too: an error
+ * message that distinguished "no such column" from "no rows" would answer the same question more slowly.
+ *
+ * @param list $filters
+ * @return list
+ */
+ private function validFilters(array $filters, DataSchema $schema): array
+ {
+ // filterable(), NOT the whole column list. A masked column renders as `******` and a filter over it
+ // answers a yes/no question about the real value — which, asked repeatedly, recovers it. See
+ // DataSchema::filterable().
+ $columns = $schema->filterable();
+
+ return array_values(array_filter(
+ $filters,
+ static fn (DataFilter $filter): bool => in_array($filter->column, $columns, true)
+ && DataFilter::isOperator($filter->operator)
+ && ($filter->value !== '' || ! $filter->needsValue()),
+ ));
+ }
+
+ /**
+ * The relations this resource's entity declares, each already matched to a browsable resource where one
+ * exists.
+ *
+ * MATCHING HAPPENS HERE and not in RelationIntrospector because it needs the REGISTRY: whether the other
+ * end of a relation is browsable depends on whether some repository declares it and whether that
+ * resource is excluded, neither of which is a fact about the model. Keeping the two apart means the
+ * introspector answers "what does this model relate to" once per class, and this method answers "and can
+ * I open it" against whatever the registry currently offers.
+ *
+ * @return list
+ */
+ public function relationsFor(string $slug): array
+ {
+ if (! $this->settings->enabled || ! $this->settings->relations) {
+ return [];
+ }
+
+ $resource = $this->registry->get($slug);
+ if ($resource === null || $resource->entityClass === null) {
+ return [];
+ }
+
+ $bySlugForClass = [];
+ foreach ($this->registry->all() as $candidate) {
+ if ($candidate->entityClass !== null && ! isset($bySlugForClass[$candidate->entityClass])) {
+ $bySlugForClass[$candidate->entityClass] = $candidate->slug;
+ }
+ }
+
+ $relations = [];
+ foreach ($this->relations->forEntity($resource->entityClass) as $found) {
+ $relations[] = new DataRelation(
+ name: $found['name'],
+ label: $this->humanise($found['name']),
+ kind: $found['kind'],
+ relatedClass: $found['related'],
+ relatedSlug: $bySlugForClass[$found['related']] ?? null,
+ column: $found['column'],
+ target: $found['target'],
+ toMany: $found['toMany'],
+ );
+ }
+
+ return $relations;
+ }
+
+ private function humanise(string $name): string
+ {
+ $spaced = trim((string) preg_replace('/(?settings->enabled) {
+ return null;
+ }
+
+ $resource = $this->registry->get($slug);
+ if ($resource === null) {
+ return null;
+ }
+
+ $repository = $this->repositoryFor($resource);
+
+ return $repository === null
+ ? null
+ : $this->engine->find($repository, $resource, $this->schemas->for($resource), $id);
+ }
+
+ /**
+ * Remove one row, addressed by the schema's identifier.
+ *
+ * The removal is verified after the fact with `existsById()` rather than trusted, because
+ * `CrudRepository::deleteById()` returns void: a repository whose delete was a no-op (a soft-delete scope
+ * that excluded the row, an override that swallowed it) would otherwise report success and the operator
+ * would watch the row reappear on the next page load.
+ */
+ public function delete(string $slug, int|string $id): DataWriteResult
+ {
+ $refusal = $this->refuseWrite($slug, $id);
+ if ($refusal !== null) {
+ return $refusal;
+ }
+
+ $resource = $this->registry->get($slug);
+ if ($resource === null) {
+ return DataWriteResult::notFound('No such resource.', $slug, $id);
+ }
+
+ $schema = $this->schemas->for($resource);
+ if ($schema->identifier === null) {
+ return DataWriteResult::refused(self::NO_IDENTIFIER, $slug, $id);
+ }
+
+ $repository = $this->repositoryFor($resource);
+ if ($repository === null) {
+ return DataWriteResult::failed(self::UNRESOLVABLE, $slug, $id);
+ }
+
+ try {
+ if (! $repository->existsById($id)) {
+ return DataWriteResult::notFound('No such record.', $slug, $id);
+ }
+
+ $repository->deleteById($id);
+
+ if ($repository->existsById($id)) {
+ return DataWriteResult::failed('The repository accepted the delete but the record is still present.', $slug, $id);
+ }
+ } catch (Throwable $e) {
+ return DataWriteResult::failed($this->engine->safeReason('The delete failed', $e), $slug, $id);
+ }
+
+ return DataWriteResult::done('Deleted.', $slug, $id);
+ }
+
+ /**
+ * Write named columns onto one existing row, through the repository's own `save()`.
+ *
+ * WHY `save()` AND NOT AN UPDATE QUERY. Going straight to the builder would be one line and would bypass
+ * everything the application attached to persistence: auditing (`created_by`/`updated_by`), optimistic
+ * locking, the aggregate tracker that dispatches domain events after commit. An admin edit that silently
+ * skips the audit trail is precisely the edit you most want audited.
+ *
+ * WHY ONLY ELOQUENT-BACKED RESOURCES. Mutating a plain entity means either calling setters the browser
+ * cannot know about or reflecting values into promoted `readonly` properties, which is exactly the
+ * invariant-bypassing `create()` still refuses for a NON-Eloquent entity (see the class docblock) — with
+ * the additional
+ * problem that on a readonly property it is not even possible. A resource whose entities are value
+ * objects is browsable and deletable, and its edit is refused with a reason.
+ *
+ * THE IDENTIFIER AND MASKED COLUMNS ARE DROPPED, NOT REJECTED. A detail form legitimately round-trips
+ * every field it rendered, including the key it addressed the row by and any column shown as `******`.
+ * Rejecting the whole submission for containing them would make the obvious form implementation fail
+ * every time; writing them would re-key the row, or overwrite a real credential with the mask. So they
+ * are dropped, and `DataWriteResult::$changed` reports exactly which columns were written — silence with
+ * a receipt, not silence.
+ *
+ * An UNKNOWN column, by contrast, is a hard refusal: it cannot come from a form this schema produced, so
+ * it is either tampering or a bug, and quietly ignoring it would hide both.
+ *
+ * `$fields` is keyed by `array-key`, not by `string`, because that is what request input actually is:
+ * PHP normalises a numeric form field name to an INTEGER key, so a POST containing `0=x` produces an int
+ * key no matter how the form was meant to be built. Declaring the honest type is what lets the unknown-
+ * column check reject it as data instead of raising a TypeError on the way in.
+ *
+ * @param array $fields column name => submitted value
+ */
+ public function update(string $slug, int|string $id, array $fields): DataWriteResult
+ {
+ $refusal = $this->refuseWrite($slug, $id);
+ if ($refusal !== null) {
+ return $refusal;
+ }
+
+ $resource = $this->registry->get($slug);
+ if ($resource === null) {
+ return DataWriteResult::notFound('No such resource.', $slug, $id);
+ }
+
+ $schema = $this->schemas->for($resource);
+ if ($schema->identifier === null) {
+ return DataWriteResult::refused(self::NO_IDENTIFIER, $slug, $id);
+ }
+
+ // The key type is int|string, not string: PHP turns a numeric form field name into an integer key,
+ // so `` in a crafted POST would hand a `string` closure an int and raise a TypeError
+ // under strict_types — a 500 from the one input this method exists to distrust.
+ $unknown = array_values(array_filter(
+ array_keys($fields),
+ static fn (int|string $name): bool => ! $schema->has((string) $name),
+ ));
+ if ($unknown !== []) {
+ return DataWriteResult::refused(
+ sprintf('%d submitted field(s) are not columns of this resource.', count($unknown)),
+ $slug,
+ $id,
+ );
+ }
+
+ $repository = $this->repositoryFor($resource);
+ if ($repository === null) {
+ return DataWriteResult::failed(self::UNRESOLVABLE, $slug, $id);
+ }
+
+ try {
+ $entity = $repository->findById($id);
+ } catch (Throwable $e) {
+ return DataWriteResult::failed($this->engine->safeReason('The lookup failed', $e), $slug, $id);
+ }
+
+ if ($entity === null) {
+ return DataWriteResult::notFound('No such record.', $slug, $id);
+ }
+
+ if (! $entity instanceof Model) {
+ return DataWriteResult::refused(
+ 'This resource is not backed by an Eloquent model, so the browser will not mutate it — see DataBrowser::update().',
+ $slug,
+ $id,
+ );
+ }
+
+ return $this->applyUpdate($repository, $entity, $schema, $slug, $id, $fields);
+ }
+
+ /**
+ * Insert a new record.
+ *
+ * WHY THIS EXISTS NOW, having been deliberately absent. The original argument was that a generic form
+ * cannot honour an entity's constructor invariants — true, and it still is for a repository over a
+ * hand-written domain object, which is why that case is still refused by name. It was never true for an
+ * ELOQUENT model: Eloquent constructs one empty and fills it by attribute, which is exactly what
+ * `update()` already does to a row that exists. Create was therefore refusing on a risk that update was
+ * already taking, and the inconsistency cost every application a CRUD surface that stopped at RUD.
+ *
+ * The same gate, the same coercion and the same unknown-field refusal apply. A column the schema calls
+ * uneditable — the identifier, a masked secret — is skipped exactly as it is on update, so a crafted
+ * POST cannot choose a primary key or write a value the page would only ever show as `******`.
+ *
+ * @param array $fields
+ */
+ public function create(string $slug, array $fields): DataWriteResult
+ {
+ $refusal = $this->refuseWrite($slug, null);
+ if ($refusal !== null) {
+ return $refusal;
+ }
+
+ $resource = $this->registry->get($slug);
+ if ($resource === null) {
+ return DataWriteResult::notFound('No such resource.', $slug);
+ }
+
+ $schema = $this->schemas->for($resource);
+
+ $unknown = array_values(array_filter(
+ array_keys($fields),
+ static fn (int|string $name): bool => ! $schema->has((string) $name),
+ ));
+ if ($unknown !== []) {
+ return DataWriteResult::refused(
+ sprintf('%d submitted field(s) are not columns of this resource.', count($unknown)),
+ $slug,
+ );
+ }
+
+ $repository = $this->repositoryFor($resource);
+ if ($repository === null) {
+ return DataWriteResult::failed(self::UNRESOLVABLE, $slug);
+ }
+
+ $model = $resource->entityClass;
+ if (! $resource->isEloquentBacked() || $model === null || ! is_a($model, Model::class, true)) {
+ return DataWriteResult::refused(
+ 'This resource is not backed by an Eloquent model. A generic form cannot honour an arbitrary '
+ .'entity\'s constructor invariants, so records for it must be created through your own use cases.',
+ $slug,
+ );
+ }
+
+ $entity = new $model;
+
+ foreach ($fields as $name => $value) {
+ $column = $schema->column((string) $name);
+ if ($column === null || ! $column->isEditable()) {
+ continue;
+ }
+
+ $coerced = $this->coerce($entity, $column, $value);
+ if ($coerced === false) {
+ return DataWriteResult::refused(
+ sprintf('The value for `%s` is not a valid %s.', $column->name, $column->type),
+ $slug,
+ );
+ }
+
+ $entity->setAttribute($column->name, $coerced[0]);
+ }
+
+ try {
+ $saved = $repository->save($entity);
+ } catch (Throwable $e) {
+ return DataWriteResult::failed($this->engine->safeReason('The insert failed', $e), $slug);
+ }
+
+ $id = $saved instanceof Model ? $saved->getKey() : null;
+
+ return DataWriteResult::done(
+ 'Created.',
+ $slug,
+ is_int($id) || is_string($id) ? $id : null,
+ array_keys($entity->getAttributes()),
+ );
+ }
+
+ /**
+ * @param CrudRepository