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:
composer require firefly/openapifirefly/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.
| 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.
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.
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(<ControllerShortName minus "Controller">) + ucfirst(<method>). 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.
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. 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.
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<X> to compile the table ArgumentResolver hydrates from.)
Three sources, in order:
/**
* A page of orders.
*
* @return array{page: positive-int, size: positive-int, total: int, items: list<Order>}
*/
#[GetMapping]
public function index(): array { /* … */ }- The
@returntype expression. The only place a PHParraycan say what is in it. Prose after the type becomes the responsedescription— the only response description anyone actually writes. - The declared return type. A class becomes a component
$ref, a backed enum its value set, a scalar itself. - Neither —
type: object, the old behaviour, kept as the fallback for a barearrayreturn with nothing said about it. A@return array<string, mixed>parses fine and means nothing, so it is treated as saying nothing rather than allowed to suppress what the declared type knew.
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>, Order[], array<int, Order> |
type: array with items: {$ref: Order} |
array<string, Money> |
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.
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
JsonSerializableserialises as whateverjsonSerialize()returns. Give that method a@return array{…}and the schema is exact. The skeleton'sApp\Orders\Orderis the case that matters: it publishes a derivedtotalthat 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<string, mixed>onjsonSerialize()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.
#[PostMapping(status: 201)]
#[ApiResponse(status: 409, description: 'That reference already exists.', type: Consignment::class)]
#[ApiResponse(status: 202, description: 'Accepted for later booking.', type: 'list<Shipment>')]
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.
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.
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<string|ValidationRule> the BeanValidator is handed at request time, and by the time it does, the scanner
has already:
- applied the Jakarta null contract — a
nullableflag prepended to every property whose declared type admits null and which carries noNullAwarerule; - expanded
#[Size]into a first-party rule object rather than Laravel's polymorphicmin:/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 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.
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.
MemberType::required() is deliberately broader than the constraint-derived answer:
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.
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 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.
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:
{ "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.
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<any>, 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:
/**
* @param list<OrderLineRequest> $lines The lines to order, at least one.
* @param OrderLineRequest[] $legacy The same thing, the older way.
* @param array<int, Fulfilment> $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 |
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<X> once was:
| Written | Was | Is |
|---|---|---|
list<string> $tags |
type: array — Array<any> again |
items: {type: string} |
list<list<int>> $matrix |
type: array |
nested items |
array<string, int> $meta |
type: array — the wrong JSON type |
type: object with additionalProperties |
The third is the one that mattered. array<string, int> 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 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<CategoryNode> $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<any> 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 schema's description is the DTO's class docblock. A member's is resolved in this precedence:
#[ApiProperty(description:)]— the author said it explicitly;- the member's own docblock;
- the constructor's
@paramline 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.
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 --output=docs/openapi.json # writes the file, prints a summary line
php artisan firefly:openapi > openapi.json # writes the raw document to stdoutThe 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 | <generator>, 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.
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.
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.
'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.
A hand-written, dependency-free reference: one inline <script>, a few hundred bytes of CSS, one fetch of the
spec route, and a dark/light palette that follows prefers-color-scheme. It does the two things a reader actually
needs and raw JSON does not give them — groups operations by tag with verbs and paths visible at a glance, and
resolves $ref pointers client-side so a 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 wants no third-party JavaScript in the response at all.
The viewer fetches the spec from the sibling route rather than having the document inlined, so an edit-and-reload
cycle shows up on a browser refresh, and so the two routes can be exposed independently — a deployment may 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.
| Key | Default | Meaning |
|---|---|---|
firefly.openapi.enabled |
true |
Master gate. Off means all three routes are genuinely unrouted, not blank. |
firefly.openapi.path |
'/openapi.json' |
The spec route. Registered with the leading slash stripped, because Illuminate's Router does that itself. |
firefly.openapi.viewer.enabled |
true |
Mount the console and its assets. The spec route stays mounted either way. |
firefly.openapi.viewer.path |
'/openapi' |
The console route; assets are mounted under {path}/assets/{file}. |
firefly.openapi.viewer.style |
'swagger' |
swagger | builtin | cdn. Unrecognised values fall back to swagger. |
firefly.openapi.viewer.cdn |
false |
Legacy boolean. true forces the CDN page and overrides style. |
firefly.openapi.title |
'API' |
Info Object title. |
firefly.openapi.version |
'0.0.0' |
Info Object version. |
firefly.openapi.description |
'' |
Info Object description; omitted from the document when empty. |
firefly.openapi.summary |
'' |
Info Object summary — the 3.1 short-form line beside description. Trimmed; an empty value is "not configured" and is never emitted as an empty member. |
firefly.openapi.terms-of-service |
'' |
Info Object termsOfService. Same trim-and-omit rule. |
firefly.openapi.contact.name | .url | .email |
'' |
Info Object contact members. The object is emitted only if at least one is set, carrying only the ones that are. |
firefly.openapi.license.name |
'' |
Info Object license. name is the gate — with it empty, no license is emitted at all, because the 3.1 License Object requires it. |
firefly.openapi.license.identifier | .url |
'' |
The other two License members. They are mutually exclusive in 3.1, so identifier wins where both are set and url is dropped rather than emitting an invalid object. |
firefly.openapi.servers |
[] |
Bare URL strings and/or OpenAPI Server Objects. An entry that is neither — or an object with no url — is dropped, because it would be invalid under the 3.1 schema and would poison an otherwise-good document. Omitted from the document when empty. |
firefly.openapi.exclude |
'' |
CSV of path prefixes left out of the document. Removes them from the spec only; it does not unroute them. |
firefly.openapi.include-html |
false |
Document #[Controller] HTML routes as text/html operations. |
The optional Info Object members live on DocumentInfo rather than on OpenApiProperties, and its constructor
argument is last and nullable, so every existing three-argument OpenApiGenerator construction — the auto-
configuration's #[Bean], an application's own override bean, the fixtures — keeps producing exactly the document
it produced before. applyTo() then rebuilds the Info Object's key order rather than appending, into the order
the specification itself lists: title, summary, description, termsOfService, contact, license, version. Nothing
consumes that order semantically; a human diffing a committed openapi.json does, and title, version, description, summary reads as an afterthought where the spec's own order reads as a table. Any non-spec member an
override bean put into info — a x- specification extension, say — survives, after the spec ones.
OpenApiProperties is read once, at BootPhase::FlushDefinitions, into an immutable value object — the same
lifetime ExposureModel has in firefly/actuator, and for the same reason: the registrar mounts routes from
specPath/viewerPath at WiringPasses, so a post-boot config()->set() on those keys could not move an
already-mounted route anyway.
The three routes are ordinary routes, and firefly/security's HttpSecurityFilter is a global middleware
pushed onto Laravel's HTTP-kernel stack, so it runs for them exactly as it runs for your controllers. Locking the
documentation down is therefore pure configuration, with no code edge — the same story as
Actuator:
'firefly' => [
'security' => [
'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 that covers the console but not its assets produces an authenticated page whose stylesheet
401s.
The alternative, for a deployment that wants no documentation surface in production at all, is
firefly.openapi.enabled => false plus a firefly:openapi --output= step in CI.
Every collaborator is a #[Bean] behind #[ConditionalOnMissingBean], so replacing one is a short
#[Configuration] in the application and never a fork:
#[Configuration]
final class ApiDocsConfiguration
{
#[Bean]
public function constraintSchemaMapper(): ConstraintSchemaMapper
{
return new HouseConstraintSchemaMapper; // teaches the generator your own ValidationRules
}
}OpenApiProperties, ConstraintSchemaMapper, DtoSchemaFactory, OperationFactory, OpenApiGenerator and
ViewerPage are all overridable this way. The pipeline is six beans rather than one god object precisely because
swapping the whole generator is rarely what anyone wants, whereas replacing just the constraint mapper (to teach
it a house ValidationRule) or just ViewerPage (to ship a corporate console) is exactly what they want.
LaraFly's rule is that nothing on the cached request path reflects. This package honours it. DtoSchemaFactory
reflects a DTO's constructor to learn its property types, but that work 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.
Teaching RouteScanner to emit per-property types into every RouteDescriptor was rejected: it would grow the
compiled route manifest of every application for the benefit of one optional package.
| Concern | Plain Laravel | LaraFly (firefly/openapi) |
|---|---|---|
| Where the spec comes from | a second description — zircote/swagger-php's @OA\ blocks, attribute classes, or a hand-kept YAML file |
the same RouteManifest the dispatcher dispatches from and the same ConstraintManifest the validator validates with |
| Drift | invisible: the document still validates, it just no longer matches the server | structurally impossible — there is no second source |
| Request-body schemas | re-declared beside the FormRequest that enforces them | derived from the compiled constraints |
| Error responses | documented by hand, if at all | one shared Problem component describing what ProblemDetailsRenderer actually returns |
| A browser console | a third-party package, usually CDN-backed | official Swagger UI from your own origin, no npm, no CDN |
| The nearest analogue | php artisan route:list — accurate for the same reason, and unable to say anything about a body |
springdoc-openapi, outside PHP |
- A success body typed
arraywith no@returndocuments astype: object. That is the fallback, not the rule — see What an endpoint returns. Write the shape in a@return array{…}(or return a DTO) and the generator publishes it. x-firefly-constraintsis the escape hatch, not a vocabulary. Anything JSON Schema cannot state lands there verbatim; no attempt is made to translate a checksum rule or a temporal predicate into an approximation that would be wrong.webhooks,securityschemes andcallbacksare not emitted —firefly/security's configuration is not reachable from this package without a code edge thatdeptrac.yamldeliberately does not permit.
See also: Web Layer for RouteManifest and the binding plan, Validation for
ConstraintManifest, Error Handling for the problem-details shape, and
Admin Dashboard for the other browser surface LaraFly ships.