Write an OpenAPI spec. Get a fully typed, PHPStan-proven PHP API server.
specbound is spec-first server code generation for OpenAPI 3.1: it turns your hand-written
spec into final readonly DTOs, one small interface per API operation, and ready-to-route
PSR-7 handlers. You implement the interfaces — plain PHP, no framework types — and the
package wires everything else. If your implementation doesn't match the spec, PHPStan
fails your build; if a route or auth middleware could drift from the spec, it can't,
because both are derived from the spec at runtime.
openapi.yaml ──generate──▶ DTOs · interfaces · PSR-7 handlers
▲
│ implements
your plain-PHP classes ──▶ PHPStan max proves the match
⚠️ Alpha. Under active development; APIs and generated output change without notice. Not on Packagist yet, not production-ready. See Status.
Five steps from spec to a served, validated, authenticated endpoint. This is the
example/ app condensed — a complete runnable Laravel project you can copy from.
# openapi.yaml
openapi: 3.1.0
info: { title: Pet Store, version: '1.0' }
paths:
/pets/{id}:
get:
operationId: getPet
tags: [ Pets ]
security:
- bearerAuth: [ ]
parameters:
- name: id
in: path
required: true
schema: { type: string }
responses:
'200':
description: The pet.
content:
application/json:
schema: { $ref: '#/components/schemas/Pet' }
'401':
$ref: '#/components/responses/Unauthorized' # produced by middleware, see below
components:
securitySchemes:
bearerAuth: { type: http, scheme: bearer }
responses:
Unauthorized:
description: Authentication required.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
schemas:
Pet:
type: object
required: [ id, name, status, tags ]
properties:
id: { type: string }
name: { type: string }
status: { $ref: '#/components/schemas/PetStatus' }
tags: { type: array, items: { type: string } }
createdAt: { type: string, format: date-time }
PetStatus:
type: string
enum: [ available, pending, sold ]
Error:
type: object
properties:
message: { type: string }Generation is a plain PHP script — configuration is code, no YAML config, no CLI flags:
// generate.php — run on every spec change (composer script, CI step, file watcher)
$specbound = new Specbound(
PhpNamespaceName::fromString('App\\Generated'),
FormatTypeMap::fromArray([
// format: date-time → real \DateTimeImmutable properties (see "Formats" below)
'date-time' => FormatType::create('\\DateTimeImmutable', DateTimeImmutableConverter::class),
]),
);
$specbound->generateToDirectory(__DIR__ . '/openapi.yaml', __DIR__ . '/app/Generated');This writes to app/Generated/ (the directory is codegen-owned — stale files from renamed
schemas are cleaned up):
| File | What it is |
|---|---|
PetResponse.php |
final readonly DTO — typed constructor, fromArray()/toArray() |
PetStatusEnum.php |
native backed string enum |
GetPetOperation.php |
the interface you implement — __invoke(GetPetRequest): PetResponse |
GetPetRequest.php |
one DTO bundling the operation's path/query/header/body inputs |
GetPetHandler.php |
PSR-7 invokable that hydrates the request, calls your implementation, serializes the response — you never touch it |
Plain PHP. No Laravel imports, no base class — constructor-inject whatever you need:
// app/Operations/EloquentGetPet.php
final class EloquentGetPet implements GetPetOperation
{
public function __construct(private readonly PetRepository $pets) {}
public function __invoke(GetPetRequest $request): PetResponse
{
$pet = $this->pets->find($request->id); // $request->id is a typed string
return new PetResponse(
id: $pet->id,
name: $pet->name,
status: PetStatusEnum::from($pet->status),
tags: $pet->tags,
createdAt: $pet->created_at, // \DateTimeImmutable — the VO, not a string
);
}
}Forget a required field, pass a string where the enum belongs, return the wrong type — PHPStan (max) rejects it. That's the point.
One config file. No routes to write, no controllers to write:
// config/specbound.php
return [
'spec' => base_path('openapi.yaml'), // in production: the resolved-$ref build artifact
'namespace' => 'App\\Generated',
// generated interface => your implementation
'operations' => [
GetPetOperation::class => EloquentGetPet::class,
],
// middleware for every spec-derived route (request validation first)
'middleware' => [ValidateOpenApi::class],
// OpenAPI security scheme => the Laravel middleware that enforces it
'security' => ['bearerAuth' => 'auth:sanctum'],
// operationIds to skip auto-registration for (wire those classically)
'except' => [],
];The package's auto-discovered ApiServiceProvider reads this and, at boot:
- binds each operation interface to your implementation,
- registers one native Laravel route per spec operation — method + path from the spec, action = the generated handler — and attaches the mapped auth middleware to every secured operation.
routes/api.php stays empty. There is no route list to keep in sync with the spec, and no
auth annotation to forget: both are derived from the spec, so they cannot drift.
php artisan serve
curl -H 'Authorization: Bearer token' http://127.0.0.1:8000/pets/rex-1
# {"id":"rex-1","name":"Rex","status":"available","tags":["good-boy"],"createdAt":"2026-07-23T10:00:00+00:00"}GET /pets/rex-1
│
├─ auth middleware ('auth:sanctum') — from the spec's security block; may return 401
├─ ValidateOpenApi middleware — league/openapi-psr7-validator against the same spec;
│ non-conforming request → 400, handler never runs
├─ GetPetHandler (generated, PSR-7) — Laravel injects the PSR-7 request natively
│ GetPetRequest::fromParts(path, query, headers, body) ← typed, coerced hydration
│ ($this->operation)($request) ← your implementation
│ json response from PetResponse->toArray()
▼
200 {"id": "rex-1", ...}
Two things worth noting:
- Hydration is generated, static, reflection-free.
fromParts()reads each input from its spec-declared location (path/query/header/body), coerces path/query strings to their PHP types, matches headers case-insensitively, and maps mangled names back to their wire form.fromArray(toArray($dto)) == $dtoround-trips are part of the test suite. - Error statuses your handler never returns are middleware-owned. 401 comes from the
auth middleware, 400 from the validator — before your code runs. That's why the generated
interface honestly returns only
PetResponse, notPetResponse|Error401|Error400: the return type describes exactly what your implementation can produce. (Configurable — seeFrameworkOwnedResponses.)
One documented success status → the interface returns the bare body DTO (as above). Several → the codegen emits one wrapper per status, and the union keeps them distinguishable:
interface CreatePetOperation
{
public function __invoke(CreatePetRequest $request): CreatePet201Response|CreatePet409Response;
}
// in your implementation:
return new CreatePet201Response(body: new PetResponse(...));Wrappers implement Runtime\HttpResponse (httpStatus(): int + toArray()), so the
generated handler serializes the right status without any lookup table.
Everything an operation receives arrives in one {OperationId}Request bundle — path params,
query params, headers, and the JSON body (as its own typed DTO):
public function __invoke(ListPetsRequest $request): PetListResponse
{
$request->page; // ?int — query param, coerced from the query string
$request->xClientId; // string — header X-Client-Id, mangled name, matched case-insensitively
}Map any format to a value object of your choice; the DTO property is the VO, never a raw
string. Ships with DateTimeImmutableConverter (date-time/date) and a generic
StringWrapperConverter for single-string wrappers (uuid, email, …):
FormatTypeMap::fromArray([
'date-time' => FormatType::create('\\DateTimeImmutable', DateTimeImmutableConverter::class),
'uuid' => FormatType::create('\\App\\Domain\\PetId', StringWrapperConverter::class),
]);StringWrapperConverter builds via YourVo::fromString($wire) (or the constructor) and
serializes via (string). A format used in the spec but missing from the map fails the
build loudly. Custom conversions: implement the two-method Runtime\FormatConverter interface.
Notification:
oneOf:
- $ref: '#/components/schemas/EmailNotification'
- $ref: '#/components/schemas/SmsNotification'
discriminator: { propertyName: kind }generates a sealed hierarchy: an abstract readonly NotificationResponse base whose static
fromArray() dispatches on kind, with both members extends-ing it. An unknown
discriminator value throws. allOf compositions are flattened into one DTO at generation
time (two members contributing the same property is a build error, not silent last-wins).
A Pet schema used in both directions generates PetRequest and PetResponse —
readOnly: true properties (server-assigned ids, timestamps) are absent from the request
class, writeOnly (passwords) absent from the response class. Resolved at generation time;
there is never a half-valid object at runtime.
Auto-registration is a default, not a cage. Put an operationId in 'except' and wire that
route however you want — streaming responses, custom middleware stacks, classic controllers.
Everything else stays derived.
Two one-assertion conformance checks, meant to run in your test suite (auto-derived routes
make them near-tautological — they earn their keep for 'except'-wired operations and
config mistakes):
// every spec operation → route exists, targets a handler, interface is bound
self::assertSame([], (new OperationCoverage($namespace))
->violations($document, $router->getRoutes(), $app));
// every secured operation → its mapped auth middleware is on the route
self::assertSame([], (new SecurityCoverage($schemeMap))
->violations($document, $router->getRoutes()));Plus the static half: run PHPStan max over your operation implementations. See
example/tests/Feature/PetApiTest.php for the full
end-to-end version.
- Ship the resolved-
$refspec as a build artifact (one self-contained file) and pointspecbound.specat it. It is parsed once per process; a deploy replaces the file, so there is no runtime cache to invalidate. - Commit
app/Generated/or regenerate in CI — either works; generation is deterministic. - The generated handler needs a PSR-17 response factory; the provider binds
nyholm/psr7's automatically (bindIf— your own binding wins).
Everything generated is framework-free: DTOs and interfaces depend on nothing, handlers
depend only on PSR-7/PSR-17 interfaces plus the tiny src/Runtime. Implementations are
callable in-process (($operation)(new GetPetRequest(id: 'x'))), and any PSR-7-speaking
stack (Slim, Mezzio, a Symfony PSR-7 bridge) can route to the generated handlers directly.
Only src/Bridge/Laravel knows Laravel exists — and a PHPStan rule enforces that the core
never imports a framework namespace.
From components/schemas + paths, per the naming scheme {Schema}{Direction} — generated
classes never collide with your domain model (Pet entity vs generated PetResponse):
| Spec construct | Generated |
|---|---|
| named object schema | final readonly {Schema}Request / {Schema}Response (per used direction only; unused schemas generate nothing) — promoted ctor params, fromArray() / toArray() |
enum (string) |
backed string enum {Schema}Enum, mangled case names ("foo-bar" → FooBar) |
| operation inputs | final readonly {OperationId}Request with location-aware fromParts(path, query, headers, body) |
| operation | interface {OperationId}Operation — single __invoke, honest return union |
| multi-status responses | {OperationId}{Status}Response wrappers implementing Runtime\HttpResponse |
| operation dispatch | {OperationId}Handler — PSR-7 invokable, framework-free |
format: X |
property typed as your configured value object, converted via FormatConverter |
type: array + items |
array + @param list<T> docblock (PHPStan-visible element type) |
type: [T, "null"] (3.1) |
?T |
wire name ≠ PHP identifier (legacy-id) |
mangled property + #[WireName('legacy-id')], wire key preserved in hydration |
default: <scalar> |
constructor default + hydration fallback |
allOf |
flattened into one DTO (property conflicts fail the build) |
oneOf + discriminator |
sealed abstract base + dispatching fromArray() |
Generated code references exactly four runtime symbols (WireName, FormatConverter,
HttpResponse, PathTemplate) — nothing else in the library, no framework.
You author the spec, so specbound supports a deliberately restricted subset of OpenAPI —
every forbidden construct is complexity that never has to exist in the mapper, the hydrator,
or your head. The linter runs at load time and fails loudly with the rule that was
violated; codegen and the runtime validator both consume the linted Document, so they cannot
disagree.
| Rule | What it deletes |
|---|---|
| OpenAPI 3.1 only | the dual 3.0 nullable: dialect |
every object schema is a named components/schemas entry (inline only scalars / arrays-of-$ref) |
synthetic names for anonymous types |
application/json only |
content negotiation, multipart, form encoding |
operationId required, unique; exactly one tag |
undefined naming |
no anyOf; oneOf only with discriminator |
the worst of schema composition |
parameters inline, default style/explode only |
most of the query-string matrix |
default values: scalar literals only |
composite default emission |
additionalProperties: T rejected (for now) |
silent map-schema mishandling — loud until implemented |
middleware-owned statuses (401/400/422/…) only as $ref to shared components/responses |
dishonest return unions |
one Document (multi-file via standard file-$refs) |
fragment merging |
| optional ⇒ nullable (convention) | the optional-but-non-nullable ctor problem |
Most PHP OpenAPI tooling runs code-first: annotate controllers, derive the spec, hope it
matches. specbound inverts that, because a derived spec can't prove anything about the
code it was derived from. Spec-first with generated interfaces gives a real completeness
chain:
- Signature exactness — your class
implementsa generated interface; PHPStan max guarantees every operation is implemented with exactly the spec's types. A missing required field or a mistyped return is an analysis error, not a runtime surprise. - Wire correctness — hydration/serialization is generated (static, reflection-free, PHPStan-visible), not runtime-reflected, so the wire format is part of what the type checker sees.
- Routing & auth correctness — routes and security middleware are derived from the spec at boot, so the classic drift ("spec says secured, route forgot the middleware") is structurally impossible rather than merely tested.
- Request validation —
league/openapi-psr7-validatorruns the same linted Document in front of every handler for the constraints types can't express (pattern,minimum, …).
Nothing on the market combined framework-agnostic + server interfaces + typed DTOs + a
PHPStan proof + no JVM — the landscape survey and all design decisions live in
docs/2026-07-23-initial-concept.md.
- PHP
^8.2(developed on 8.4) - Core deps:
devizzent/cebe-php-openapi(3.1 parser),nette/php-generator,psr/http-message+psr/http-factory(interfaces only) - Laravel bridge (optional, same package, auto-discovered): add
league/openapi-psr7-validator,nyholm/psr7,symfony/psr-http-message-bridge
Not yet on Packagist — install via a path repository (see
example/composer.json).
Built strictly test-first. Suite: 126 tests / 336 assertions green, PHPStan max clean, plus the example app's end-to-end feature tests.
Working today
- Codegen core: DTOs (both directions, usage-pruned), enums, request bundles with
fromParts()(location-aware, coercing), operation interfaces with honest unions, per-status wrappers, PSR-7 handlers, scalar defaults,allOf,oneOf+discriminator,#[WireName]mangling,format→VO converters -
Specbound::generateToDirectory()— one-call build step incl. stale-file cleanup - Style-guide linter (all rules above) wired into the load path
- Laravel bridge: auto-discovered provider (bindings + spec-derived routes + security
middleware),
ValidateOpenApirequest validation,OperationCoverage/SecurityCoverageconformance helpers - Proofs: round-trip execution tests, runtime
implementscheck, subprocess PHPStan run asserting a mistyped__invokefails analysis, forbidden-framework-namespace PHPStan rule - Runnable example Laravel app (
example/)
Not yet
-
additionalPropertiesmap schemas (linted loud, unimplemented) - Packagist release, CI, tagged versions
- Symfony bridge (same shape, on demand)
mise run test # PHPUnit
mise run analyse # PHPStan max (single-process, see .mise.toml)
mise run check # both
mise run example:generate # regenerate example/app/Generated from example/openapi.yaml
mise run example:test # example app feature testsTests are Behat-flavoured Given/When/Then as named private helper methods in plain PHPUnit (no magic base class). Fail-loud behavior is a first-class thing to test.
MIT — see LICENSE.