Technical Stack
In brief
- The Status column is binding: a
PlannedorUndecidedrow is not a settled decision, and nobody may present it as one.- PHP 8.3 through 8.5, Laravel 12 and 13, and
devizzent/cebe-php-openapias the parser, for its OpenAPI 3.1 support.- Pest, Pint and Larastan at level 8 are the toolchain, and all three are
Decided.- A choice that moves from
PlannedtoDecidedis updated in the change that installs it.
The technology choices behind lara-spec-first, and the reasoning that produced them.
The project is in early bootstrap (Phase 1 of the Roadmap). composer.json now declares the Decided rows below; the Planned ones are not installed yet. The Status column is binding: a Planned or Undecided row is not a settled decision and must not be presented as one.
Every choice, with its status
One row per decision, and the Status column is the part that binds:
| Concern | Choice | Status | Notes |
|---|---|---|---|
| Language | PHP ^8.3 | Decided | Minimum 8.3. Do not use syntax newer than 8.3 in src/, since it must still parse on the lowest supported runtime. 8.2 was ruled out: its security support ends 2026-12-31. |
| Framework | Laravel 12.x, 13.x | Decided | Constraint ^12.0 | ^13.0. Laravel 11 is excluded: it reached end of life on 2026-03-12. It can be added back later as a minor release if users ask: widening support is non-breaking, narrowing it is not. |
| OpenAPI parser | devizzent/cebe-php-openapi | Decided | Drop-in fork of cebe/php-openapi adding OpenAPI 3.1 support. Same cebe\openapi\ namespace, so the two cannot coexist in one project. |
| OpenAPI versions | 3.0.x and 3.1.x | Decided | Both versions are parsed; a documented subset is honored. Modern design tools export 3.1. Feature coverage, and the version differences behind it, live in openapi-support.md. |
| OpenAPI version handling | Strategy per minor version | Decided | One concrete strategy per OpenAPI minor version, so 3.0/3.1 differences stay in one place and a third version is a new class rather than an audit. Seam and open questions: openapi-support.md. |
Remote $ref policy | Domain allowlist, empty by default | Decided | A spec that can fetch a URL is a network client running with the app's credentials. Allowlisted hosts resolve; anything else is a clear exception. A fetched reference is then treated as a dependency, never as a cache entry: the copy is vendored and committed, so the runtime never touches the network and an upstream change arrives as a reviewable diff. No lock file, because git already is one. Rules: remote-references.md. Config keys, file names and command names not yet chosen. |
| YAML decoding | symfony/yaml | Decided | Constraint ^7.4 | ^8.0. Direct rather than transitive because the pipeline decodes the document before the OpenAPI parser sees it: version detection, root shape and $ref cycle detection all work on the decoded array. Laravel does not depend on symfony/yaml: until now it arrived through the OpenAPI parser, which is exactly the someone-else's-implementation-detail this row exists to stop relying on. The floor is ^7.4 rather than ^7.0 because that is the oldest minor anything in the graph installs, so it is the oldest one check:lowest exercises at all. The exact patch it resolves to is whatever the rest of the graph allows, and is not itself a promise. |
| Package test harness | orchestra/testbench | Decided | Boots Laravel inside the package test suite. Its major tracks Laravel minus two. Constraint ^10.11 | ^11.0: testbench 10.11 needs Laravel ^12.55, testbench 11 needs Laravel ^13.1 and PHP ^8.3. |
| Development app | orchestra/workbench | Decided | A real Laravel application in workbench/ with the package loaded, served through Compose. Ships with testbench, so it costs no extra dependency and replaces maintaining a separate Laravel project. |
| Test runner | Pest | Decided | Runs on PHPUnit underneath, so PHPUnit assertions and testbench integration still apply. Write new tests in Pest style (it(), expect()). Constraint ^4.7 | ^5.0: Pest 5 requires PHP ^8.4, so PHP 8.3 resolves to Pest 4. |
| Subprocess assertions | symfony/process | Decided | Constraint ^7.2 | ^8.0, which is the widest range every cell of the support matrix can satisfy: Laravel 12 and Testbench 10 require symfony/process ^7.2, and symfony/process 8.1 needs PHP 8.4.1. The ^8.1 this row carried until the CI matrix existed was the version Pest 5 pulls in on the newest cell, and it made the other five unresolvable, which is exactly the failure a matrix that only ever runs one combination cannot show you. Direct rather than transitive, on the same reasoning as the YAML decoding row: Pest already requires it, but a test that spawns one deliberately is depending on it, not benefiting from it arriving along for the ride. The one use so far is tests/Conformance/KnownParserBugsTest.php, which pins a parser defect that once killed the PHP process outright. An unrecoverable fatal cannot be asserted in-process without taking the test run down with it, because the run making the assertion is the run that would have to survive it. That defect is guarded against now, and the child process stays: it is still the only thing that can tell "reported a fault" apart from "would have died a moment later", so the case that asserted a death now asserts a survival. |
| Subprocess at diagnostic time | exec() (PHP core) | Decided | The only shell-out in src/, and one call: Doctor\Checks\InstallationCheck asks git check-ignore whether the vendored reference directory is excluded, because git already resolves nested files, core.excludesFile and negation correctly and a second implementation of that would only be a source of false negatives. symfony/process is require-dev, see the row above, and one diagnostic-time call does not justify promoting it to a production dependency, which is the whole reason this row exists rather than a silent exec() in a package that documents every other technology choice. Two limits, both stated at the method: without git on the PATH the check is silent rather than failing, and the exit code is read through exec()'s own output parameter rather than a 2>/dev/null appended to the command string, which is not cmd.exe syntax and made the check silently never fire on Windows. |
| Contract testing | Spectator | Planned | Validates responses against the spec in CI. |
| Response DTOs | spatie/laravel-data, or only its shape | Undecided | A DTO is final readonly; hydration lives in a generated factory instead, so from($model)-override ergonomics are no longer the draw they were. A dependency still buys casting, validation and serialization; it also binds generated code to another package's API and release cycle. Reasoning: response-dtos.md. |
| Frontend types | openapi-typescript, run by the project | Decided | Not a dependency, and that is the decision: this package emits PHP, and a consumer that wants typed requests and responses in the browser runs openapi-typescript over its own document. Emitting the types here would put Node in the path of spec:build and add a schema-to-type mapping for a language this project does not build in. Reasoning: publishing.md. |
| Mocking | Faker | Planned | Phase 2: fallback responses for unimplemented endpoints. |
| Diagrams | PlantUML, pre-rendered to SVG | Decided | Sources under docs/diagrams/, rendered by scripts/build-diagrams.sh to an SVG committed beside each one, pinned to plantuml/plantuml:1.2025.4 by digest as well as by tag, because a tag can be repushed and the check compares byte for byte. Pre-rendered rather than rendered by the site, because neither GitHub nor VitePress renders PlantUML on its own and a page that references an image works in both. Runs through Docker, like Prettier and for the same reason: the development image carries no Java. A local PlantUML is the fallback for a write and is refused for diagrams:check, whose comparison only trusts the pinned image. diagrams:check fails when a committed SVG no longer matches its source, and when one has no source left. Reasoning: documentation.md. |
| Code style | Laravel Pint | Decided | Constraint ^1.30, laravel preset (pint.json). Run composer format to apply, composer format:check to report only. pint.json excludes the Workbench's generated tree, which is the same advice this package gives its consumers: your formatter and the build. |
| Static analysis | larastan/larastan | Decided | PHPStan with Laravel-aware extensions. Constraint ^3.10, level 8 (phpstan.neon.dist). Started strict deliberately: raising the level on an existing codebase is far harder than starting there. The analyse script passes --memory-limit=512M, because the analysis outgrew PHP's common 128M default and the failure it produces is a crashed worker rather than a finding. |
| Command runner | just | Decided | Optional convenience only. Every recipe wraps a Composer script and must never carry logic of its own, or the native path stops matching. The justfile opens with a security note: it executes shell commands. |
| Dev environment | Docker + Compose | Decided | Provided, not required. Dockerfile defines the environment, compose.yaml defines how it is invoked (bind mount, host UID/GID, Composer cache, and the port mapping serve needs). Both only ever run Composer scripts, never their own logic. |
| CI | GitHub Actions | Decided | Runs the suite without Docker across PHP 8.3 / 8.4 / 8.5 x Laravel 12 / 13: six combinations, all valid, no exclude block needed. .github/workflows/tests.yml adds a lowest-dependency run (through composer check:lowest, so the package list lives in one place) and a Pint plus PHPStan job, then gathers all of them behind one All checks passed job. That gate is the job to mark required in branch protection, which is a repository setting and not part of this row: its point is that the required check keeps its name when the matrix changes. The workflow carries no paths filter on purpose: a required check that is filtered out never reports, and a pull request waiting on a check that never reports cannot be merged. .github/workflows/docs.yml is the documentation site. |
| Distribution | Packagist | Planned | Package name decided: gcob/lara-spec-first, namespace Gcob\LaraSpecFirst\. Not yet published: publishing and the first tag are still ahead. |
| Markdown formatting | Prettier | Decided | Pinned to 3.9.6 in scripts/format-markdown.sh; settings in .prettierrc.json and .editorconfig. The only command that runs outside the PHP container: the image is php:8.3-cli-alpine and Prettier needs Node, so the script prefers a local npx and borrows a throwaway node:22-alpine otherwise. Not a Composer dependency, and not part of composer check. Commands: documentation.md. |
| Documentation site | VitePress | Decided | ^1.6.4, configured in .vitepress/config.mts. srcDir is the repository root, so README.md, AGENTS.md and CONTRIBUTING.md are pages without moving out of the root, and README.md is the home page. Every audience is published, and placement is in documentation.md. Local search, a sitemap, and a build that fails on a dead internal link. Headings are slugged the way GitHub slugs them (markdown.anchor.slugify calling the github-slugger devDependency, rather than a copy of its rule that was wrong on the first non-ASCII heading), so one hand-written anchor works in both renderers; docs:check-anchors covers the fragment the build never looks at. The sidebar is generated from each document's front matter title; the config holds order and grouping only. .github/workflows/docs.yml builds the site on every pull request, and deploys it to GitHub Pages from main once Pages is set to GitHub Actions. base and the sitemap hostname assume gcob.github.io/lara-spec-first/. Commands: documentation.md. |
| License | MIT | Decided |
Supported versions at a glance
| Laravel 12 | Laravel 13 | |
|---|---|---|
| PHP 8.3 | supported | supported |
| PHP 8.4 | supported | supported |
| PHP 8.5 | supported | supported |
Every combination is valid, which is the point of the PHP 8.3 floor: the CI matrix needs no exclude block. Laravel 13 requires PHP 8.3, so a lower floor would have introduced an impossible cell.
Why these versions
Support windows, as published by Laravel and PHP:
| Release | Security support ends |
|---|---|
| Laravel 11 | 2026-03-12, already past |
| Laravel 12 | 2027-02-24 |
| Laravel 13 | 2028-03-17 |
| PHP 8.2 | 2026-12-31 |
| PHP 8.3 | 2027-12-31 |
| PHP 8.4 | 2028-12-31 |
| PHP 8.5 | 2029-12-31 |
Two consequences shaped the floor:
- PHP 8.2 would have expired before this package reached 1.0. Shipping on a floor that dies within months means raising it soon after, and raising a minimum is a breaking change requiring a major version bump. Starting at 8.3 avoids spending a major on housekeeping.
- Laravel 11 is already out of security support. Supporting three majors instead of two costs matrix cells, compatibility code, and the use of any API introduced after L11, paid for a user base that does not exist yet. Adding L11 back later is a minor release; dropping it later would be a major one. When in doubt, start narrow and widen on request.
Changing anything here
A stack decision is a durable commitment, not an implementation detail:
- Record the reasoning, not just the value. Every row above should let a reader six months from now understand why without redoing the research.
- Move a row to
Decidedin the same change that installs it. The table drifts fastest when a dependency lands and the status is updated "later". - Raising a minimum version is breaking. PHP or Laravel floors, dropped majors, and removed extension points all require a major release. Widening support does not.