Skip to content

sandstorm/specbound

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

32 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

specbound

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.


Quick start (Laravel)

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.

1. Write the spec

# 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 }

2. Generate

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

3. Implement the interface

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.

4. Configure the bridge

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.

5. Run

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"}

How a request flows

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)) == $dto round-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, not PetResponse|Error401|Error400: the return type describes exactly what your implementation can produce. (Configurable — see FrameworkOwnedResponses.)

Real-world patterns

Multiple response statuses

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.

Request bodies, query and header parameters

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
}

format → your value objects

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.

Polymorphism: oneOf + discriminator

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).

One schema, two shapes: readOnly / writeOnly

A Pet schema used in both directions generates PetRequest and PetResponsereadOnly: 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.

Escaping the happy path

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.

Keeping yourself honest in CI

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.

Production notes

  • Ship the resolved-$ref spec as a build artifact (one self-contained file) and point specbound.spec at 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).

Using it without Laravel

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.


What gets generated (reference)

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.

The spec style guide (lint-enforced)

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

Why (the rationale)

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:

  1. Signature exactness — your class implements a 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.
  2. 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.
  3. 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.
  4. Request validationleague/openapi-psr7-validator runs 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.

Requirements & installation

  • 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).

Status

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), ValidateOpenApi request validation, OperationCoverage / SecurityCoverage conformance helpers
  • Proofs: round-trip execution tests, runtime implements check, subprocess PHPStan run asserting a mistyped __invoke fails analysis, forbidden-framework-namespace PHPStan rule
  • Runnable example Laravel app (example/)

Not yet

  • additionalProperties map schemas (linted loud, unimplemented)
  • Packagist release, CI, tagged versions
  • Symfony bridge (same shape, on demand)

Development

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 tests

Tests 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.

License

MIT — see LICENSE.

About

spec-first OpenAPI server codegen for PHP, type-safe, PHPStan-proven

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages