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.xtag, Phase 2, then1.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.
SpecDocumentReaderproduces aParsableSpecDocument, 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.
OperationExtractoris the crossing point: references are resolved there and only there, and nothingcebe\openapi\returns leaves the class. - [x] The
Contract\types.Operation,HttpMethod,PathTemplate,Audience,LifecycleandSecurityRequirement. The lifecycle extensions are read with their defaults resolved and an unrecognized value refused rather than silently taken as the default, and an operation'ssecuritydistinguishes inheriting the document's requirements from explicitly requiring nothing. - [x] Configuration. The publishable config file, and
ConfigurationMergermerging 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_hostsis empty by default and every remote reference is refused before the parser can fetch it. Naming a host letsspec:build --update-refsfetch it once and commit the copy undervendor_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.phpand 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,finalunlessx-controllernames 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 writex-controllerinto 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.--yestakes 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 againstBuildPlanner, the same classspec:buildcalls, and never reachGeneratedTree::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 neitherParsing\nor the YAML decoder, andDoctor\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 Pestarch()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 neitherParsing\nor the YAML decoder. Oneroutes.phpat the root of the generated tree, loaded through Laravel's ownloadRoutesFrom(), and a missing one is silence rather than an exception becausespec:buildis a command of this same package. Serializability is verified rather than hoped for, by the real command: a test runsroute:cacheover a generated tree, then requires the cache file it wrote and checks the routes come back working. The writing half is nowspec: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, anoperationIdPHP cannot carry, and two operations claiming one class name.lara-spec-first.spec.pathnames 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 theSpecControllerbase with itsmiddleware()method, asserted on the classes a real build produces rather than on the text that emitted them. Andx-controlleritself: read intoContract\Operationand refused there when it is not a name PHP could carry, naming the generated parent and dropping itsfinal, 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:routeActiondeclares 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 madex-controlleruseless on every templated path. Found in the Workbench, where a child answersGET /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/~1escaping, 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, inroutes.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-controllerbecame the only source of an extendable name: every other generated class isfinal, 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.gitignorechoice. 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 (byoperationId, 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 nox-controller, and runs the build when it is done, without which theextendsit 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:buildnever 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--yestakes the proposal for a developer who does not want to be asked. The edit happens on a copy beside the original, so that every$refresolves 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'srouteActionthrows an exception that Laravel renders as501, 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 thespec:makeinvocation that creates the class, which was owed once that command existed and is paid.
Reading, reporting, refusing
[x] Remote reference vendoring.
spec:build --update-refsfetches an allowed reference once, commits the copy underremote_references.vendor_path, and rewrites the$refto 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
$refcycle exhaust memory instead of raising, andcomponents.pathItemsloses 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, inReferenceFormTest.php); by the positions where OpenAPI mixes data with specification (DataSpecificationBoundaryTest.php); by document shape (empty, no paths, webhooks-only, components-only, inDocumentShapeTest.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: writingDataSpecificationBoundaryTest.phpagainst the full reading engine, rather than against the cycle guard alone, is what surfaced the third defect above. A$refwhose JSON pointer lands inside data the guard correctly treats as opaque (anexample, an Example Object'svalue) 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, soKnownParserBugsTest.phpran it in a child process and asserted the exit code and stderr, intests/Support/extract.php, and the "Subprocess assertions" row instack.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.phppinned is guarded against. A$refwhose JSON pointer resolves into a keyReferenceCycleDetectortreats as opaque, anexample, an Example Object'svalueor an item of the JSON Schemaexampleslist, 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$refinside 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.yamlis refused where it used to be accepted, since it aims a reference into a JSON Schemaexampleslist 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$refinside data is data, is unchanged and is now pinned byexamples-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 underParsing\Guards\andOperationExtractorreturn what they found instead of throwing: aReadOutcomecarrying every operation that could be extracted and every fault encountered, blocking or not.spec:buildandspec:makekeep 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 letsspec:doctorbecome 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 ofspec:build/spec:makecan observe and deserves its own tests proving that. See openapi-support.md.[x]
spec:doctor, which isnginx -tfor 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. AGET /{owner}/{repo}written first swallows every two-segment GET after it, which is the shape real specifications make this mistake in;GeneratedTreegained a read-onlydiff()besidewrite()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,0clean,1a document fault,2a package limit with no document fault alongside it, withDeferredexcluded 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.--jsonships alongside the text report in this same release rather than after it. The lifecycle rules and thesecurityfinding are their own items below, not this one.[x] The lifecycle rules in the doctor.
deprecated: truerequiringx-sunset, a sunset in the past, a sunset nothing can read, thebetalisting, and the protection report counting how many public operations are actuallystable. An unrecognizedx-lifecyclevalue 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 notDeferredgates 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]
securityis 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 declaressecuritya 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 indoctor.mdif it is ever wanted, and this document does not assume it. Shipped as its own section of the report, atPartialrather thanDeferred, so it gates: a contract that declaressecurityexits2until enforcement lands, which is the point rather than a side effect. The rootsecurityblock 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
FormRequestper operation, derived from the request body and parameter schemas, withPUTrequiring the full body wherePATCHmakes fields optional. It is what supplies$validatedto 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. Whetherspatie/laravel-databecomes a dependency or only an influence isstack.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
extendsit, 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-modeland the CRUD defaults. TheHasModelinterface with itsInteractsWithModeltrait, 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 hintx-modelsupplies. 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
Sunsetheaders 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-modelis what makes the private document genuinely sensitive, and because the work is larger than it looks: excludingx-audience: internaloperations outright, then pruning transitively what they orphan (components, emptied path items, dangling tags), and reporting what was removed.
Authorization the contract can express
- [ ]
securitybecomes an authorization check. ThesecuritySchemesname matched to a Laravel guard by nomenclature, the onefinalbuilt-in middleware asking one question, theHasSecurityScopesinterface 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 bearerandoauth2are the clear fits;mutualTLSand the details ofopenIdConnectmay 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
laravelbuilt-in driver, the envelope DTO per paginated operation beside its item and metadata DTOs, thegetPaginator()andrespondWithCollection()seams (only the first of which depends onx-model), and the doctor finding for a paginated response whose operation declares no pagination parameters. See pagination. - [ ] Rate limiting. The
headersandextensionbuilt-in drivers, windows first-class from the first release because a dimension added later costs a major, the normalizedresetspelling, 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
501handler 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 thatspec:builddeliberately 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 outputbuildwould 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
excludeblock, plus the lowest-dependency run and a Pint plus PHPStan job. It is.github/workflows/tests.yml, and the CI row instack.md. Every job feeds oneAll checks passedgate, 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.phpare inert, and the file already admitted it in a comment: aTODOblock 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 a0.xactually 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. A0.1.0that 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 a0.xat 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 the1.0gate. - [x] A command reference document.
commands.mdowns 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-firstand cut0.1.0. ThePlanneddistribution row instack.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; at1.0a 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,
paginationandrate_limitingand 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: theroutes.phpfilename 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; after1.0, each one costs a major. - [ ] Close the support-matrix rows a stable release cannot leave
Open. Chiefly: whether a document containingtracefails 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 tooptionsandhead. 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
stableoperation, naming theinfo.versionbump 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
publictointernal, 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 sameinfo.versionbump 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
Spectatorfor automated contract testing in CI/CD pipelines. Itsstack.mdrow isPlannedand moves toDecidedin 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.