Skip to content

Technical Stack

In brief

  • The Status column is binding: a Planned or Undecided row 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-openapi as 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 Planned to Decided is 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:

ConcernChoiceStatusNotes
LanguagePHP ^8.3DecidedMinimum 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.
FrameworkLaravel 12.x, 13.xDecidedConstraint ^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 parserdevizzent/cebe-php-openapiDecidedDrop-in fork of cebe/php-openapi adding OpenAPI 3.1 support. Same cebe\openapi\ namespace, so the two cannot coexist in one project.
OpenAPI versions3.0.x and 3.1.xDecidedBoth 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 handlingStrategy per minor versionDecidedOne 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 policyDomain allowlist, empty by defaultDecidedA 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 decodingsymfony/yamlDecidedConstraint ^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 harnessorchestra/testbenchDecidedBoots 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 apporchestra/workbenchDecidedA 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 runnerPestDecidedRuns 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 assertionssymfony/processDecidedConstraint ^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 timeexec() (PHP core)DecidedThe 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 testingSpectatorPlannedValidates responses against the spec in CI.
Response DTOsspatie/laravel-data, or only its shapeUndecidedA 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 typesopenapi-typescript, run by the projectDecidedNot 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.
MockingFakerPlannedPhase 2: fallback responses for unimplemented endpoints.
DiagramsPlantUML, pre-rendered to SVGDecidedSources 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 styleLaravel PintDecidedConstraint ^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 analysislarastan/larastanDecidedPHPStan 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 runnerjustDecidedOptional 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 environmentDocker + ComposeDecidedProvided, 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.
CIGitHub ActionsDecidedRuns 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.
DistributionPackagistPlannedPackage name decided: gcob/lara-spec-first, namespace Gcob\LaraSpecFirst\. Not yet published: publishing and the first tag are still ahead.
Markdown formattingPrettierDecidedPinned 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 siteVitePressDecided^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.
LicenseMITDecided

Supported versions at a glance

Laravel 12Laravel 13
PHP 8.3supportedsupported
PHP 8.4supportedsupported
PHP 8.5supportedsupported

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:

ReleaseSecurity support ends
Laravel 112026-03-12, already past
Laravel 122027-02-24
Laravel 132028-03-17
PHP 8.22026-12-31
PHP 8.32027-12-31
PHP 8.42028-12-31
PHP 8.52029-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 Decided in 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.