Changelog
In brief
- Every released version has an entry here, newest first, patches included and nothing ever archived.
- The format is Keep a Changelog plus one section of our own,
Known limits, and the numbering is semantic versioning.- A
0.xmakes no compatibility promise: what a1.0will freeze is named in the roadmap rather than left to be discovered here.Unreleasedholds what is merged and not tagged. Cutting a tag renames that heading and opens a new one.
Unreleased
Changed
parameters(header,cookie) moved fromDeferredtoIgnoredin the support matrix. The package will not validate a header or a cookie parameter, so the row states a position rather than a gap, and a contract declaring one will exit non-zero until the consumer acknowledges it. Every move on the exit-code axis is major in either direction, which is why a docs change is recorded here. The doctor still counts them besidequeryunder oneDeferredlabel until a parameter becomes a rule, so nothing about today's output changed.Media type encodingandcontentMediaTypemoved up toPartial, both read for what makes amultipart/form-datapart a file. Moving up the ladder cannot break a contract that worked before, so this half is minor.Local $ref within the documentmoved down, which the row itself announced as "a major, on the release that reads schemas". A reference aimed at a position inside a$defsis now refused. It used to be accepted and it never worked: the parser does not model that keyword, so the target was a plain array it could not build a schema from, and the property carrying the reference was dropped without a word. A contract declaring three fields came back with two.- Five schema keywords moved to
Rejected:$id,$dynamicRef,$dynamicAnchor,$recursiveRefand$recursiveAnchor. Each changes how a reference resolves, so ignoring one resolves a reference to a target the document never named, which is a wrong value rather than a missing one. - A keyword the parser hands back raw is refused when a
$refis inside it:prefixItems,contains,unevaluatedItems,patternProperties,propertyNames,dependentSchemas,unevaluatedProperties,contentSchema,if,thenandelse. The refusal reads the value and not the keyword, so the same keyword holding inline schemas is still ignored as before, and a$refwritten insideexample,examples,default,enumorconststays the literal it is.
What this means for a contract that used to build. All of the above turn a build that passed into one that refuses, which is why they are recorded together. None of them was ever served correctly: each produced a value that was wrong rather than one that was missing, and nothing downstream failed to say so.
Fixed
- A position naming a schema written in another file was wrong, and it was wrong in three readings at once: a refusal named a pointer into the root document, which has nothing at it and does not name the offending file; a recursion marker cut one level too late, because the parser hands back a copy of an externally resolved node and the walk compared object identity; and a schema's own name was unrecoverable. One cause,
getDocumentPosition()reporting the first site that referenced a node, so one correction: the extractor now walks the raw document itself. Every message about a schema written outside the root document changes, from a bare#/paths/…pointer toother.yaml#/components/schemas/…. They were wrong before, so this is a correction rather than a break, but a consumer matching on those strings will see it. For a schema written in the root document, the reported pointer is now always where it is written, which it was not whencomponentswas read afterpaths. A percent-encoded$reffragment (%7Bfor{, as a bundler writes it) is read the way the parser reads it, so one schema is one name on every road to it.
Added
Contract\Schemagainsname, what the document calls a schema: a key ofcomponents.schemasin whichever file holds it, or the file name of a schema that is a whole file. Never derived from how the$refreaching it was spelled, so splitting a specification across files renames nothing. Null for a schema written inline.Contract\Schemaand the types around it:SchemaType,RequestBody,QueryParameterandResponse. A schema now reaches every generator normalized and free of any OpenAPI version, so nothing downstream branches on which one the document declared.Contract\OperationgainsrequestBody,queryParametersandresponses, plusresponse()andwithController(). Nothing is frozen under0.x, and this is the kind of move that stops being free once it is: code constructing anOperationpositionally, or defining its ownwithControllerdecorator around one, sees the surface change.
0.1.0 - 2026-09-11
The first release, and what Phase 1 set out to prove: an OpenAPI contract becomes routes and controllers, and nothing at runtime ever opens a specification.
Added
- Contract-driven routing. The service provider registers the routes
spec:buildemitted, by loading one generated PHP file. It reads no specification, at boot or ever. spec:build, which resolves the contract and emits the routes plus one controller per operation. It plans every file in memory before writing any of them, so a document it refuses leaves the working tree exactly as it was.spec:make, the only command that creates a class you own, in three forms: one named operation, a whole--tag, or--all. It never overwrites a file that exists.spec:doctor, which reports what this package will and will not honor in your contract, plus the routing table that results. Read-only, with--jsonfor CI and tooling.- The two-class seam. Generated controllers are abstract and yours extend them, so a contract change becomes a static analysis error rather than a runtime surprise. An operation nobody has implemented answers
501and names the command that implements it. - Remote reference vendoring.
spec:build --update-refsfetches an allowed reference once and commits the copy. Every other command reads the working tree and never the network. - OpenAPI 3.0 and 3.1, read through one version strategy per minor and pinned by a conformance suite that writes the same contract in both and requires them to normalize identically.
- Lifecycle and security reporting.
deprecated: truerequires anx-sunset, and an operation declaringsecurityis reported rather than enforced, in those words, because the route is registered with no authorization check behind it.
Known limits
Not a Keep a Changelog section. It is here because a release that ships a gap on purpose owes it a line, and none of the six standard sections says "this works, up to here".
securityis reported, not enforced. A contract declaring it exits2on every doctor run until enforcement lands. See security.md.- Four configuration blocks are inert, and each names the phase that makes it live. Changing one has no effect today.
- No response DTOs, no generated validation, no pagination or rate limiting. All of it is Phase 2.
Why this is one file
Every release lands here, patch included, and nothing is ever moved out. Both halves of that get asked, so both are answered here rather than re-argued each time.
A patch gets an entry because a patch is the release nobody reads before taking. Under 0.x a breaking change lands in a minor, which makes 0.1.1 the upgrade a consumer applies without thinking about it. That is exactly when it matters that there is something to read if there is something to know. The entry usually costs one line under Fixed, and a change with nothing to tell a consumer does not get tagged at all, so the empty entry never comes up.
Laravel and Symfony split theirs, and the reason is not the version number. They maintain several release lines at once and backport fixes across them, so each branch carries its own history and earns its own file. This project maintains one line: Phase 1, then 0.x, then Phase 2, then 1.0. Splitting that would produce several files describing one straight line, and a link to a version's notes would break the day that version moved.
So the criterion is more than one maintained line, not more than one minor. The day a fix is backported into 0.1.x while 0.2.0 is already out, per-branch files start paying for themselves. Until then they cost a reader the one thing this file is for, which is not having to work out where to look.
And the split would be by major. CHANGELOG.md would keep the current major and earlier ones would move under docs/. A major is already the boundary where a consumer changes worlds; a minor is a boundary for nobody. Size on its own is not a reason to do it: ten releases a year at twenty lines each is two hundred lines a year, and anyone who wants one version on its own already has its GitHub release page.