Skip to content

Architecture & API Engineering / Payload → Handler → Resource

Your API Changed. Did Your Docs Notice?

By ·

Semitexa connects the accepted request, its handler, and the declared response in one pipeline. The API description can follow those executable declarations, so structural changes do not depend on someone remembering to rewrite a separate contract.

A second description creates a second place to forget

A developer renames an input from topic to category and adds available to a response. The application uses the new names, while an independently maintained specification still describes yesterday’s interface. A client follows the specification and gets a different result from the one it expected.

Semitexa’s answer starts with Payload → Handler → Resource. The Payload declares the request boundary and the response class. A handler is bound to that pair and fills the response. Resource DTO metadata describes the data that the response exposes. These declarations participate in execution and supply the facts used to describe the API.

That connection changes the maintenance model. You update the application’s declared interface; discovery and generation read the updated declarations. Field lists, response links, and supported resource structure can remain current without keeping a second handwritten model in step. The following example traces both sides of that connection using the installed framework.

The pipeline is the source of the contract

Payload defines what comes in. Its route attribute declares the path, methods, and responseWith. Its properties and setters provide the request’s type-level input shape. The runtime hydrates that Payload before route handlers run; the input description reflects the declaration of that same class.

Handler connects the use case to its response. #[AsPayloadHandler(payload: ..., resource: ...)] binds the handler to the declared request and response classes. The handler works with those concrete objects and shapes the result. It is the executable connection between the input and output boundaries; generation does not need to infer a schema by reading its business logic.

Resource defines what goes out. For the Resource JSON API used here, #[ResourceObject] and field metadata describe the DTO. The JSON renderer and resource schema generator both consume its registered metadata. A response class identifies its DTO through #[ProducesResourceObject], completing the link from the Payload’s responseWith to the output fields.

Payloaddeclared request and response →Handler
Handlerpopulates the response →Resource
Declarationsshared input and output metadata →API contract
The runtime and the API description draw from the same declared boundaries. The handler provides the behavior between them.

This addresses the coordination problem behind generated developer interfaces. Cloudflare’s September 28, 2026 Forge announcement describes previews of generated CLI, SDK, and documentation changes. In Semitexa, the connection begins inside the application pipeline: the declarations used to process requests also supply the description. No Forge integration is required for the mechanisms shown here.

Change the Payload, and its input description follows

We first checked the existing local blog endpoint:

curl -X OPTIONS http://semitexa.test/blog

It returned HTTP 200 with an optional nullable string named topic in input.fields. That field list is derived from the Payload. It is not a separate input specification maintained for this article.

To exercise a change without modifying the live endpoint, we created two isolated fixture Payloads. The first declares public ?string $topic = null;. The second declares public ?string $category = null; and points to its updated response:

#[AsPublicPayload(
    path: '/article-example',
    methods: ['GET'],
    responseWith: PipelineResponseAfter::class,
    renderProfile: RenderProfile::Json,
)]
final class PipelinePayloadAfter
{
    public ?string $category = null;
}

Running both through DefaultRouteContractAssembler changed the generated input field from topic to category. The assembler uses PayloadMetadataReflector for that input shape. It then links available response metadata and package-contributed blocks into the contract. We did not edit an input-schema file to obtain the new field name.

Change the Resource, and the response description follows

The example’s original Resource has id and label. The updated Resource adds a non-nullable available field. Here is its actual declaration, with imports omitted:

#[ResourceObject(type: 'article.example')]
final readonly class PipelineResourceAfter implements ResourceObjectInterface
{
    public function __construct(
        #[ResourceId] public string $id,
        #[ResourceField] public string $label,
        #[ResourceField(description: 'Whether this item accepts a request.')]
        public bool $available,
    ) {}
}

The response wrapper declares which DTO it produces:

#[ProducesResourceObject(PipelineResourceAfter::class)]
final class PipelineResponseAfter extends JsonResourceResponse {}

The Handler is bound to the updated pair. Its typed method fills that response with an instance of the declared DTO:

#[AsPayloadHandler(
    payload: PipelinePayloadAfter::class,
    resource: PipelineResponseAfter::class,
)]
final class PipelineHandlerAfter
{
    public function handle(
        PipelinePayloadAfter $payload,
        PipelineResponseAfter $response,
    ): PipelineResponseAfter {
        $response->withResource(
            new PipelineResourceAfter('item-1', $payload->category ?? 'all', true),
            new RenderContext(
                RenderProfile::Json,
                IncludeSet::empty(),
                payloadClass: $payload::class,
            ),
        );
        return $response;
    }
}

We ran the typed handlers directly with the installed response renderer. The updated response contained {"data":{"id":"item-1","label":"architecture","available":true}}. The assembled contract gained available in output.fields, including its non-nullability and description. The generated OpenAPI component gained the property and listed it as required.

These observations establish the shared field structure across execution and description. They also exposed an exact-type limitation in this installed generator, explained below. The fixtures exercise real framework components in isolation; they add no application routes and do not exercise HTTP dispatch or authorization.

OPTIONS and OpenAPI project the declared interface

OPTIONS answers a route-level question: what does this endpoint declare? For the Resource API fixture, the assembled document includes both input and output. The API package’s contributor resolves the response-to-Resource link, and the core assembler serializes the registered output metadata.

OpenAPI presents supported Resource JSON routes and their component schemas as an API document:

bin/semitexa openapi:dump

ResourceRouteSchemaGenerator reads the Payload’s path, methods, render profile, and responseWith, then follows the response’s Resource declaration. ResourceSchemaGenerator builds components from ResourceMetadataRegistry. The JSON renderer reads that registry too. For declared collection policies, the route generator also obtains collection facts from the assembler that supplies OPTIONS.

These are projections of shared declarations, with different coverage. Our local blog is an HTML route with an input-only OPTIONS document; it is not included in the Resource OpenAPI export. The isolated JSON example has a linked Resource and a generated OpenAPI operation. A handwritten tutorial remains editorial content and still needs updating when its example changes.

Structural updates no longer require a parallel model

The benefit is concrete: the supported description can follow the interface the application declares. Instead of copying a change into independent field lists, the developer changes the appropriate boundary and regenerates or serves its current projection.

Where the interface is declared and how its description follows
Declaration changesExecution usesDescription follows
Payload input fieldThe typed request passed to handlersThe reflected input field list
Payload responseWithThe selected response and handler pairThe linked Resource response declaration
Resource field addedThe DTO rendered into response dataOutput fields and OpenAPI component structure
Declared collection policyCollection support applying the policyContract and OpenAPI collection parameters

The handler still has to populate the result correctly. The pipeline supplies an explicit vocabulary for that result, while discovery and generation reuse the declarations instead of reconstructing them from arbitrary returned arrays.

Does a current contract mean existing clients remain compatible?

A contract can accurately describe a changed interface that an older client cannot use. Renaming an input keeps the new description current; supporting the old name or arranging a migration is a separate compatibility decision.

State precisely what the shared source guarantees

The architecture removes a major source of drift: separately maintained structural descriptions. It keeps supported generated facts tied to the current discovered Payload and Resource declarations. It does not infer every business rule from a handler, update a previously exported file on someone else’s machine, or prove that an older client accepts a changed interface.

There is also a concrete implementation boundary. In this checkout, ResourceSchemaGenerator::scalarSchema() emits string for scalar fields. Our bool $available therefore appears as a required OpenAPI property but has the wrong scalar type, even though the rendered JSON contains a boolean. Shared field metadata keeps its presence current; exact scalar typing needs a richer generator. We retain this observation so the architectural benefit is not mistaken for a claim that every schema detail is already exact.

Business meaning must be declared or explained as well. A status field does not by itself explain whether a request is submitted or confirmed. Our booking case follows that distinction.

Keep the published contract on the same version as the service

Change the Payload and Resource declarations with the use case, and keep the handler bound to the intended pair. Generate the description from that version of the application. Refresh workers and discovered metadata through the project’s normal deployment lifecycle, and publish exported artifacts from the same release.

Then test an actual consumer request, especially when a change affects compatibility or business meaning. Generation keeps the supported contract facts connected to the implementation; runtime checks establish that the intended deployment serves them correctly. The external-agent article explains why that usable description matters when a customer sends software to your service.

Payload → Handler → Resource gives the team a maintainable API boundary. The accepted input, the code handling it, and the output model belong to one connected application structure. API descriptions can follow that structure as it evolves, rather than becoming a second implementation that somebody has to remember to maintain.

One connected API boundary

Let the contract follow the pipeline.

Explore the Semitexa documentation, inspect a Resource route’s OPTIONS response, and generate its API description from the same application version.

Have a product idea?One free MVP every month