Skip to content

Project Roadmap: lara-spec-first

In brief

  • A checked box means behavior with tests behind it, never a design somebody agreed to.
  • Phase 1 runs today: a contract becomes routes and one controller per operation, each answering 501 until somebody implements it.
  • The order is Phase 1, the 0.x tag, Phase 2, then 1.0. Breaking-change enforcement and Phase 3 both come after that.
  • Where the code is today is the authoritative list of what exists. Every other document defers to it.

This document owns the sequencing. What each feature is, and why it was designed that way, lives in the document that owns the subject; what lands when, and in which order, is decided here.

Two rules keep it usable:

  • A checked box is behavior with tests behind it. Not a design that has been agreed on, and not a document that describes one. Where the code is today is therefore verifiable rather than aspirational, which is the only reason a roadmap in a repository this early is worth reading at all.
  • A feature document names its phase, and this file is where the phase comes from. A guide whose banner says a feature has no phase is a gap in this document, not in that one.

The order, end to end: Phase 1, then the 0.x tag, then Phase 2, then 1.0. Breaking-change enforcement and Phase 3 both come after 1.0, and their order relative to each other is open. Saying that out loud is the point of the two gates having their own section: the phases alone cannot express when something ships, and "when does this become a compatibility promise" is the most consequential question this document has to answer.

Where the code is today

The package reads a specification and generates the PHP that serves it: an operation in a contract becomes a route that answers, honestly, with 501 until something implements it. Nothing at runtime opens a specification.

What runs

  • [x] Package structure. composer.json, PSR-4 autoloading, the service provider with package discovery, and the development toolchain (Docker, Pest, Pint, Larastan, just).
  • [x] The reading pipeline, five steps in the order openapi-support fixes and explains: decode, detect, shape, cycles, remote references. SpecDocumentReader produces a ParsableSpecDocument, and nothing reaches the parser until all five have passed.
  • [x] The version strategy. One class per OpenAPI minor version behind one interface, plus the factory that dispatches on the detected version. Pinned by a conformance class that writes one contract as 3.0 and as 3.1 and requires both to normalize identically.
  • [x] The parser integration. OperationExtractor is the crossing point: references are resolved there and only there, and nothing cebe\openapi\ returns leaves the class.
  • [x] The Contract\ types. Operation, HttpMethod, PathTemplate, Audience, Lifecycle and SecurityRequirement. The lifecycle extensions are read with their defaults resolved and an unrecognized value refused rather than silently taken as the default, and an operation's security distinguishes inheriting the document's requirements from explicitly requiring nothing.
  • [x] Configuration. The publishable config file, and ConfigurationMerger merging the package defaults deeply beneath whatever an application published, so a nested key added in a later release does not arrive missing.
  • [x] The remote-reference allowlist, fetching and vendoring included.remote_references.allowed_hosts is empty by default and every remote reference is refused before the parser can fetch it. Naming a host lets spec:build --update-refs fetch it once and commit the copy under vendor_path; every build after that resolves the reference against the committed copy, never the network.
  • [x] Route registration at boot. The provider loads one generated routes.php and nothing else, skips it when the application's routes are cached, and stays silent when the build has not written one. The loading half only: what the routes point at is not generated yet.
  • [x] spec:build. Reads the configured specification, plans every file in memory, then writes: the routes and one controller per operation, each explaining its own provenance and answering 501, final unless x-controller names a class of the project's own, in which case the route reaches that class instead once it exists. Idempotent, confined to the generated tree, and it prunes what the contract no longer describes.
  • [x] spec:make. The only command that creates a file a project will own: one operation, a whole --tag, or --all, with the bulk forms listing what they would create and asking first. It offers to write x-controller into the specification when an operation declares none, the value prefilled and editable, the edit verified on a copy, never overwrites a file, and runs the build afterwards so the class it wrote has a parent to extend. --yes takes every proposal for a developer who would rather not be asked.
  • [x] spec:doctor, in its Phase 1 form. Read-only, always: Routing outcome and Drift only ever plan against BuildPlanner, the same class spec:build calls, and never reach GeneratedTree::write(). Reports the outcome as well as the problems, since the resolved routing table prints even on a clean run, and never mixes a document fault with a package limit in one exit code. See the doctor and its item below.
  • [x] The architecture assertions. The parser is contained to Parsing\, Contract\ is forbidden from knowing anything about the layer that produced it, Routing\ may reach neither Parsing\ nor the YAML decoder, and Doctor\ may not write a file at all, the read-only guarantee the doctor's own docblock claims, asserted rather than assumed, along with the direction of its dependencies. All of them are Pest arch() tests rather than conventions to remember.

What does not exist yet

What is missing is no longer a namespace but the second half of several features: security is reported and not enforced, breaking-change enforcement has no baseline to compare against, and there is no response DTO or generated validation. Four of the nine blocks in config/lara-spec-first.php are marked TODO in the file itself and are inert, which the file says out loud rather than leaving to be discovered, and which the first tag labels with the phase that makes them live rather than removing.

Phase 1: The Foundation

Goal: one operation in a specification becomes one route that answers, and everything the package will not honor is said out loud before anything runs.

The slice is deliberately thin, and stops before persistence. No x-model, no response DTO, no generated FormRequest: those need each other, and pulling them in would mean this phase never demonstrates the whole path from a contract to an HTTP response. What it proves instead is the property every later phase rests on: the contract reaches the code, and a gap in it is loud.

Generating and registering

  • [x] Routing\: the service provider registers the generated routes. It does not read the spec, at boot or ever, and an architecture assertion says so rather than a convention: Routing\ may reach neither Parsing\ nor the YAML decoder. One routes.php at the root of the generated tree, loaded through Laravel's own loadRoutesFrom(), and a missing one is silence rather than an exception because spec:build is a command of this same package. Serializability is verified rather than hoped for, by the real command: a test runs route:cache over a generated tree, then requires the cache file it wrote and checks the routes come back working. The writing half is now spec:build's, which emits that same file, so what is proven here is the loading, originally against a fixture standing in for generated output, and since then against the real thing in the Workbench.
  • [x] spec:build, in its Phase 1 form: resolves the specification and emits the routes and the generated controllers. Idempotent (a second run against an unchanged document does not touch a file, not even its modification time), ordered (every file is planned in memory before any is written, so a refused document leaves the working tree untouched), and it never writes outside its own directories, the invariant stated without a clause precisely so that it can be tested as one, which it is. It also refuses what it cannot serve rather than emitting it: a path parameter Laravel's router would never match, one past the compiler's 32-character ceiling, an operationId PHP cannot carry, and two operations claiming one class name. lara-spec-first.spec.path names the document, and stale generated files are pruned by the marker they carry, so nothing a human wrote inside the tree is ever removed.
  • [x] The two-class seam. One controller per operation carrying one routeAction, over the SpecController base with its middleware() method, asserted on the classes a real build produces rather than on the text that emitted them. And x-controller itself: read into Contract\Operation and refused there when it is not a name PHP could carry, naming the generated parent and dropping its final, with the route pointing at the child once that class has a file the autoloader can find and at the parent until then. Two values reducing to one generated parent is a build error naming both, and so is one naming a class inside the generated tree, which would extend itself. It also settled a signature: routeAction declares one parameter per path parameter, named as the document names them, because PHP forbids an override from adding a required parameter, and a parameterless parent would have made x-controller useless on every templated path. Found in the Workbench, where a child answers GET /users/{id} for real while the operations around it still answer 501.
  • [x] Every generated file explains itself. The source map (the JSON pointer the file came from) and the docblock norm (provenance, findings, navigation), emitted unconditionally and asserted by the generator's own tests. It shipped with the first generated file rather than after it: retrofitting a convention across a generated tree is an audit, writing it into the first emitter is a paragraph. What a finding can say will grow with what the build knows; the norm itself is in place. Both emitters have a test class of their own, where the parts are asserted one by one: every finding, the pointer's ~0/~1 escaping, the blank line a formatter would otherwise insert, a value from the document that would close the comment early, a token too long for a line, and the absence of anything (a clock above all) that would make two runs differ, while the command's own test asserts the complementary property over every file it writes rather than a sample of one: that the norm is there at all. The reference comment landed with them, in routes.php, the only generated file that references other generated code today.
  • Rename and orphan detection: dropped, not pending. It was designed, built against the source map above, and removed before it shipped, so this is a decision recorded rather than work waiting. The premise expired when x-controller became the only source of an extendable name: every other generated class is final, so the only broken import the comparison could have predicted follows an edit its own author just made. What it would still have caught, a custom controller left extending nothing after its operation left the contract, is that author's call to make, and reading the previous build's output could never have been a CI guarantee anyway, since whether that output exists is a .gitignore choice. The full reasoning is in code-generation; the reference comment a generated file carries is what does the cheap half of that job today, and the doctor is where the orphan question lands if it is ever wanted.
  • [x] spec:make: the only command that creates a file the developer will own. Shipped in its three forms: a named operation (by operationId, or by method and path when it has none), a whole --tag, or --all, with the bulk forms listing what they would create and asking first, defaulting to no so a non-interactive run creates nothing. It never overwrites a file, refuses a class in a namespace the project does not map, offers the row to add for an operation that declares no x-controller, and runs the build when it is done, without which the extends it just wrote has no parent to reach, since that parent's name comes from the extension the build had not read. What it writes is deliberately not a publishable stub: nearly every line is derived, and a template is a way to reintroduce guessing into the one file where nothing is guessed. spec:build never scaffolds and now names the commands to run instead, summarised by tag, with the atomic form named for the operations no tag reaches, and the generated 501 names it too. The insertion prompt closes the item: the value is derived from the configured controller namespace and prefilled so it can be edited, the exact line is named, and --yes takes the proposal for a developer who does not want to be asked. The edit happens on a copy beside the original, so that every $ref resolves as it did, the copy is read back through the normal pipeline, and the operations that come out must be identical to the originals but for the extension just added. Anything else leaves the document untouched and prints the row instead, which is also what happens for an operation the command cannot place: a flow-style mapping, a JSON file, or a Path Item that lives in another file.
  • [x] An unimplemented operation answers 501. The generated controller's routeAction throws an exception that Laravel renders as 501, which is what reconciles the two things this documentation set said: the generated controller is the handler position, so one controller per operation stays true. See 501. It is the seam the Phase 2 mock plugs into, so its position is settled now rather than later. The body names the spec:make invocation that creates the class, which was owed once that command existed and is paid.

Reading, reporting, refusing

  • [x] Remote reference vendoring. spec:build --update-refs fetches an allowed reference once, commits the copy under remote_references.vendor_path, and rewrites the $ref to point at it, followed transitively, so a vendored document naming a reference of its own is vendored too, the allowlist checked again at every hop. Frozen by default: every other build reaches the network only when that flag says so, and a missing vendored copy is an error naming it instead. See remote references.

  • [x] A conformance suite over the reading engine, organized by equivalence class. Not routine coverage, but a deliberate answer to a risk already observed. Three defects with no symptom have now been found in the OpenAPI parser, on a surface no wider than paths and references: two shapes of a pure $ref cycle exhaust memory instead of raising, and components.pathItems loses an endpoint without reporting anything. All three are recorded in parser caveats, and none would have been prevented by putting an interface in front of the parser: an adapter guards against swapping a dependency, where what has actually gone wrong is the dependency being wrong. Behavior is therefore what gets pinned.

    The suite partitions the input space rather than accumulating examples, so that coverage can be argued instead of hoped for: by version, with the same contract written as 3.0 and as 3.1 and required to normalize identically (the version strategy's entire promise, in VersionEquivalenceTest.php); by reference form (local, cross-file, blocked, cyclic, recursive schema, and each form a Path Item reference can take, in ReferenceFormTest.php); by the positions where OpenAPI mixes data with specification (DataSpecificationBoundaryTest.php); by document shape (empty, no paths, webhooks-only, components-only, in DocumentShapeTest.php); and by failure class, keeping document faults, package limits and parser defects distinct in the assertions the way the doctor keeps them distinct in its report (FailureClassTest.php).

    Every defect found in the parser earns a permanent case in KnownParserBugsTest.php, so the list of what we know about it can only grow, and it already has: writing DataSpecificationBoundaryTest.php against the full reading engine, rather than against the cycle guard alone, is what surfaced the third defect above. A $ref whose JSON pointer lands inside data the guard correctly treats as opaque (an example, an Example Object's value) reaches the same unrecoverable failure as an ordinary cycle, on a shape a rule about key names cannot see. Pinning it came first and guarding it came after, in the item below: an unrecoverable fatal cannot be asserted in the same process as the test runner without taking the run down with it, so KnownParserBugsTest.php ran it in a child process and asserted the exit code and stderr, in tests/Support/extract.php, and the "Subprocess assertions" row in stack.md. That child process is still there now that the defect is guarded, asserting the survival where it used to assert the death, because it remains the only thing that can tell the two apart. The suite ends up being what an adapter was wanted for regardless: the acceptance criteria a replacement parser would have to meet. An interface would only prove a substitute compiles; this proves one behaves.

  • [x] The third parser defect KnownParserBugsTest.php pinned is guarded against. A $ref whose JSON pointer resolves into a key ReferenceCycleDetector treats as opaque, an example, an Example Object's value or an item of the JSON Schema examples list, exhausted the parser's memory instead of raising, the same failure the cycle guard exists to prevent, on a shape a rule about key names cannot see. Of the two ways out this roadmap named, only one could work: a depth or step ceiling would never have fired, because the guard's own walk terminates immediately on these documents rather than running away, so the missing edge is not a long chain but no chain at all. The guard therefore follows a pointer's target into the data it lands in, and only a target: a literal $ref inside an example stays a literal until a Reference Object aims at the position holding it, which is the one moment the parser reads it as specification and therefore the one moment we must too. The conformance suite's subprocess case is green, inverted rather than deleted: it now pins that these documents leave the interpreter standing. One documented boundary moved with it, and is worth naming rather than leaving to be noticed: tests/Fixtures/schema-examples-list.yaml is refused where it used to be accepted, since it aims a reference into a JSON Schema examples list and only survived the parser by accident of 3.1 keywords being handed back as raw arrays; the boundary it was written to protect, that a $ref inside data is data, is unchanged and is now pinned by examples-list-is-data.yaml, which carries the same literal with nothing pointing at it. See parser caveats.

  • [x] The reading pipeline stops refusing at the first fault. SpecDocumentReader, the guards under Parsing\Guards\ and OperationExtractor return what they found instead of throwing: a ReadOutcome carrying every operation that could be extracted and every fault encountered, blocking or not. spec:build and spec:make keep today's behavior exactly, inspecting the outcome and refusing the moment it carries a fault, but the decision moves from the pipeline to its callers, which is what lets spec:doctor become a third caller reading the same contract rather than a second, divergent code path that has to be kept in sync by hand with every future check. A prerequisite for the item below, landed on its own rather than folded into it, since it changes nothing a consumer of spec:build/spec:make can observe and deserves its own tests proving that. See openapi-support.md.

  • [x] spec:doctor, which is nginx -t for your contract: what the package will honor, what it will not, and the routing table that results. It belongs in this phase rather than with the Phase 2 developer experience, because it is what makes "the spec is the source of truth" verifiable rather than asserted. See the doctor. Shipped in its Phase 1 form: configuration, document validity, version, references, support findings, routing outcome, drift and installation, the eight sections whose inputs already existed. The two that do not, baseline and drivers, still print, each with a [not checked] line naming what it does not diagnose, so a zero exit is never read as covering them; security and lifecycle came off that list in the change that built them, which is the only way an entry there is meant to be removed. Routing outcome includes shadowing, where an earlier templated path would match every request a later one was meant to answer, literal or templated. A GET /{owner}/{repo} written first swallows every two-segment GET after it, which is the shape real specifications make this mistake in; GeneratedTree gained a read-only diff() beside write() so Drift compares against the exact same logic a build would apply rather than a second implementation of it. The two kinds of finding stay distinct in the exit code, 0 clean, 1 a document fault, 2 a package limit with no document fault alongside it, with Deferred excluded from both, however many of them a real document carries: a construct the roadmap has not built yet must never fail a pipeline over it. --json ships alongside the text report in this same release rather than after it. The lifecycle rules and the security finding are their own items below, not this one.

  • [x] The lifecycle rules in the doctor. deprecated: true requiring x-sunset, a sunset in the past, a sunset nothing can read, the beta listing, and the protection report counting how many public operations are actually stable. An unrecognized x-lifecycle value was already refused where the document is read, so it stays a Document validity fault rather than being reported a second time here. A sunset merely approaching is reported beside the protection report rather than as a finding, with a configurable horizon (lifecycle.sunset_horizon_days, 90 days by default): every finding that is not Deferred gates the exit code, and a date crossing a horizon must never fail a pipeline on a day nobody committed anything. See the doctor rules.

  • [x] security is reported, not enforced, and the report says so in those words. Enforcement is Phase 2, and a phase that registers routes without it must not let a consumer mistake a documented promise for a kept one. So Phase 1 owes an operation whose contract declares security a finding stating that the package does not yet apply it, and that finding takes the treatment doctor.md already reserves for security: every affected operation listed individually, on every run, never folded into a count. That is the existing rule applied, not a new category. Whether a finding can also be made impossible to acknowledge is a question about the acknowledgement mechanism, so it belongs in doctor.md if it is ever wanted, and this document does not assume it. Shipped as its own section of the report, at Partial rather than Deferred, so it gates: a contract that declares security exits 2 until enforcement lands, which is the point rather than a side effect. The root security block stays Open and is named in one line rather than claimed to be understood.

Phase 2: the generated pipeline

Goal: prove the thesis. For an ordinary CRUD endpoint, the route, the form request, the controller and the DTO are all derived from the contract, and the only thing a developer writes is the model and the business logic that model carries.

Not less typing for its own sake: less surface where the code and the contract can quietly disagree. Everything here is spec:build and spec:doctor doing more, never a new command, with two exceptions that say so explicitly (spec:watch and the mock server).

The pipeline

  • [ ] Generated request validation. One FormRequest per operation, derived from the request body and parameter schemas, with PUT requiring the full body where PATCH makes fields optional. It is what supplies $validated to everything below, so it comes first.
  • [ ] Response DTOs. final readonly, generated from the response schema, with no abstract layer to extend because a value object mirroring the contract has no behavior of its own to grow. Whether spatie/laravel-data becomes a dependency or only an influence is stack.md's row to settle, in the same change that installs or declines it.
  • [ ] DTO factories. One generated per DTO, mapping by name, with a default that covers the ordinary case. Overridden by a class that extends it, found by scanning the directories a project declares and nothing else, with exactly one override per factory and a hard error naming both classes when two claim one.
  • [ ] x-model and the CRUD defaults. The HasModel interface with its InteractsWithModel trait, the empty marker interface naming the semantic the build detected, the generated create, update, delete and read bodies, and route-model binding driven by the type hint x-model supplies. Plus the doctor check that only makes sense once writes are generated: comparing a model-aware operation's validated fields against the bound model's mass-assignment rules, because Eloquent drops the rest without raising.
  • [ ] RFC 8594 Sunset headers from the generated code. The lifecycle keys are already read and the doctor already enforces them; this is the third thing they buy, and it needs a response path to attach to, which is why it lands here rather than in Phase 1. Declared once in the spec, enforced in CI, advertised over HTTP, with nobody writing that code.
  • [ ] The sanitized public copy of the specification. Off unless a project names a disk, so Phase 1 publishes nothing by default and leaks nothing. It lands here because x-model is what makes the private document genuinely sensitive, and because the work is larger than it looks: excluding x-audience: internal operations outright, then pruning transitively what they orphan (components, emptied path items, dangling tags), and reporting what was removed.

Authorization the contract can express

  • [ ] security becomes an authorization check. The securitySchemes name matched to a Laravel guard by nomenclature, the one final built-in middleware asking one question, the HasSecurityScopes interface the authenticated model implements, and the requirement resolved into the generated route at build time rather than read from the spec per request. The Phase 1 "not enforced" finding is deleted in the same change, and the doctor's security section becomes real: a scheme with no matching guard, a scheme type the middleware cannot enforce, a model missing the interface.
  • [ ] Decide which scheme types reduce to "the model has a scope". apiKey, http bearer and oauth2 are the clear fits; mutualTLS and the details of openIdConnect may not be answerable by one middleware at all, and a scheme it cannot enforce is a case for acknowledgement, never a silent pass.

The driver features

The driver mechanism lands with the two features that need it rather than ahead of them, because an extension point designed without a second implementation in front of it is a guess. Both features are extras that remove redundancy: nothing breaks without them.

  • [ ] The driver mechanism itself, and above all its registration API, which is public API surface under rule 4 and has to be decided once for every driver-based feature at once. Each feature publishes an interface for the contract and an abstract class for the boring half.
  • [ ] Pagination. The laravel built-in driver, the envelope DTO per paginated operation beside its item and metadata DTOs, the getPaginator() and respondWithCollection() seams (only the first of which depends on x-model), and the doctor finding for a paginated response whose operation declares no pagination parameters. See pagination.
  • [ ] Rate limiting. The headers and extension built-in drivers, windows first-class from the first release because a dimension added later costs a major, the normalized reset spelling, and the doctor finding for 429 declarations that are not structurally identical across operations. One thing has to be decided before the adapter is more than an interface: whether what reads it is build-time enforcement or a runtime relay. See rate limiting.

Mocks and the design loop

  • [ ] Faker-based mocking for operations with no implementation. It replaces the body of the 501 handler rather than adding a mechanism: same route, same handler position, a better answer. Its hard part is not Faker, it is the parser caveat that hands 3.1 schema keywords back as raw arrays with unresolved $ref, which is where this feature will actually be spent.
  • [ ] A mock server driven by the spec: serve the whole contract with conforming responses, with no application behind it. Distinct from the fallback above, which fills the gaps in a real application, and the first execution context that legitimately reads a specification outside a build. It is where the runtime never sees the spec gets the enumeration of contexts it deliberately deferred.
  • [ ] spec:watch: the design loop, rebuilding on change and allowed to fetch references that spec:build deliberately refuses to. A separate command because a running process states intent every time and dies with the terminal, where a config key would quietly follow you into CI. Its one hard rule is that it must never produce output build would not. See watching.
  • [ ] Spec-driven test data, so that testing an endpoint does not start by writing a factory. The schema already states the shape, the constraints and often the examples. Where this stops matters and must be said plainly: a schema describes shapes, not domain truth. Referential integrity, business invariants and database constraints are not in it. Spec-driven data can replace a factory for HTTP-level and mock-server tests; it cannot replace one for tests that persist to a database.

Release gates

Goal: publish nothing we would have to break, and do not wait for perfection to publish anything at all.

Two gates, and each one follows a phase. Neither is a phase itself: nothing here is a feature. What they hold is the work that only matters because somebody else can now depend on it.

Inside each gate the items are independent of one another, with one exception that is the same both times: the tag is not a parallel item, it is the door the others hold shut. Cutting it is what makes everything above it expensive, which is the entire reason the gate exists.

The first tag: 0.x, once Phase 1 runs

A 0.x is a deliberate choice rather than a placeholder. It gets the package into hands while rule 4's promises are still explicitly not being made, which is the only window in which a name can be corrected for free. Waiting for Phase 2 would mean the first outside reader arrives after every decision is already unchangeable.

  • [x] The CI test matrix. PHP 8.3 / 8.4 / 8.5 against Laravel 12 / 13, six valid combinations with no exclude block, plus the lowest-dependency run and a Pint plus PHPStan job. It is .github/workflows/tests.yml, and the CI row in stack.md. Every job feeds one All checks passed gate, which exists so that branch protection can require a check whose name survives a change to the matrix. Making it required is a repository setting, not something the workflow can do for itself: until somebody sets it under Settings > Branches, the suite runs on every pull request and merging a red one is still allowed.
  • [x] Say when every inert config key stops being inert. Four of the nine blocks in config/lara-spec-first.php are inert, and the file already admitted it in a comment: a TODO block is inert, changing it has no effect, and nothing will tell you so. What it did not say was when that ends, which is the half a reader of a 0.x actually needs. Each inert block now names its phase, and so does the one key inside an otherwise working block that is not read yet. This reverses what this item used to ask for, which was to remove those blocks before publishing: the right answer for a release where a published key is a promise, and the wrong one here. A 0.1.0 that says out loud it is unstable is allowed to show the shape it is heading toward, and showing it is most of why somebody reads a 0.x at all. What was never allowed is letting a key look live when it is not, and a phase label is what separates the two. Removal moves to the 1.0 gate.
  • [x] A command reference document. commands.md owns what each of the three Phase 1 commands writes, every argument and flag, how all three resolve the specification, what each exit code means, and what changes when no terminal is attached. The doctor deferred its usage details to it and now keeps only the reasoning, which is the split the rest of the set follows: the reference says how, the guide beside it says why.
  • [ ] Publish to Packagist as gcob/lara-spec-first and cut 0.1.0. The Planned distribution row in stack.md.

Before 1.0: freeze what a major would cost

Everything a consumer writes code against stops being ours to change here.

  • [ ] Settle every config key that is still inert. Each one either ships with the feature behind it or comes out of the file. A phase label was enough for a 0.x; at 1.0 a key a consumer can read is a key a consumer can depend on, and adding one back later is widening, which is only minor. This is what the first gate deferred.
  • [ ] Freeze the public names. Under rule 4 every one of these becomes a compatibility contract, and each is currently marked open in the document that owns it: the config keys (generated path and namespace, the override scan, the publish block, pagination and rate_limiting and every key inside their mappings), the Artisan command signatures and their flags, the controller interface, trait and method names, the exception class names, the vendored directory and the refetch flag, the driver registration API, and the generated tree's own layout: the routes.php filename and its position at the root of that tree, which the build's writer and the runtime's reader both have to agree on, and the marker every generated file carries, which is not decoration: it is what decides whether the build may delete a file, so changing its value orphans every tree an earlier version wrote and nothing will ever prune them again. Settling them here costs nothing; after 1.0, each one costs a major.
  • [ ] Close the support-matrix rows a stable release cannot leave Open. Chiefly: whether a document containing trace fails to load or only the operation is refused, whether a non-conforming path parameter name is rejected absolutely or has an escape hatch for specs the consumer does not own, and what happens to options and head. Each is behavior a consumer writes code against.
  • [ ] Decide the versioning directories (v1/, v2/), or drop them. Carried over from the first roadmap and still an intention rather than a decision: no document owns it, which under one topic, one file means there is nothing to implement against. It sits in this gate rather than among the Phase 2 features because a versioned generation tree changes the paths and namespaces the build emits, which puts it under rule 4 exactly like the config keys above. It either earns a design and a home before the names are frozen, or it leaves the roadmap.
  • [ ] Cut 1.0, which is the release that starts costing a major to get wrong.

Breaking-change enforcement

Goal: a stable operation cannot break without someone deciding to break it.

Deliberately not slotted into a phase: a rule that fails somebody's build has to be right before it ships, and the breaking-change table is large enough to deserve its own body of work rather than being smuggled into a release. See lifecycle.

Which means the consequence has to be stated rather than left to be noticed: until this lands, x-lifecycle: stable is a declaration the doctor reports on, not a rule that fails a build. That is already the position lifecycle.md takes, and it is why the doctor's protection report exists from Phase 1: protection that is off must never look like protection that passed.

  • [ ] Diff the specification against its previously committed version, read from git rather than from a separate file the build writes, normalizing 3.0/3.1 differences in memory before comparing. The doctor's baseline check is the prerequisite: without it, a shallow clone or an untracked specification compares against nothing and reports a clean run for the wrong reason.
  • [ ] The breaking-change table, direction-aware for requests and responses, versioned as public API in its own right.
  • [ ] Fail the build on a breaking change to a stable operation, naming the info.version bump that would make it legitimate. Enforcement as instruction rather than as an obstacle.
  • [ ] Report the two ways a promise can be revoked without a break being visible: demoting an operation from public to internal, and the exclusion from the published copy that demotion causes, which is the most breaking change there is for whoever was already calling it. Whether either merely reports or requires the same info.version bump is open.

Phase 3: Legacy Bridge & Ecosystem

Goal: turn an existing Code-First Laravel app into a Spec-First one, quickly, simply, and above all reliably.

The hard part of adopting Spec-First is not the new code, it is the app you already have. An established API has its contract scattered across controllers, form requests, and resources, and nobody wants to retype it into YAML by hand.

The plan is to use existing Code-First tooling (Scramble, L5-Swagger) once, as an on-ramp: extract a spec from the code you already run, then flip the direction of truth so the spec leads from that point on. It is a one-way door, not a permanent round-trip.

Three properties matter, in this order:

  • Reliable. You should be able to trust that the extracted spec actually describes what your API does today, before you hand it the keys. A migration you cannot verify is not a migration.

  • Simple. Adoptable route by route, never a big-bang rewrite. That is the shape spec:make --tag= already has, which is not a coincidence: adopting tag by tag was designed for this phase.

  • Fast. The boring parts should be mechanical.

  • [ ] Tooling to bootstrap a spec from an existing Code-First app, and to verify it against real behavior before cutover. (Design in progress, more to come.)

  • [ ] First-class integration with Spectator for automated contract testing in CI/CD pipelines. Its stack.md row is Planned and moves to Decided in the same change that installs it.

  • [ ] The driver ecosystem. A driver is worth publishing as a package because it carries structure and no project's field names, and the set of pagination and rate-limit conventions in the wild is larger than this package should ever ship. What is owed here is a naming convention for community drivers, and a doctor that names the resolved driver for each feature including third-party ones.

  • [ ] Documentation of the migration path, with examples taken from real applications.