← Routing & Handlers

ROUTING & HANDLERS FEATURE

Payload Parts

A payload can stay the single trusted boundary even when multiple modules need to extend it and guard their own fields.

Feature Guide

A quick orientation block that answers the essential questions: what this feature does, how it works, why it matters, and the key concepts behind it.

What this does

A payload can be extended by another module without reopening the original route class, so one transport boundary can stay singular while modules stay additive.

How it works

A base module declares the payload with #[AsPayload]. Another module contributes a trait marked with #[AsPayloadPart(base: ...)]. At runtime PayloadFactory composes a wrapper class that extends the base payload and uses all matching traits, so the added trait can own setters and guards for its own extra fields.

Why it matters

This solves a painful modularity problem: extra request concerns do not force a fork of the original payload and do not leak into untyped arrays. The handler still receives one trusted DTO, and each added concern can validate its own field without a central validate() choke point.

Key concepts

#[AsPayloadPart]
Marks a trait as an additive extension of an existing payload class.
PayloadFactory
Builds the runtime wrapper class that extends the base payload and mixes in discovered payload-part traits.
trait composition
Lets separate modules add typed setters, getters, and local guards to the same request boundary without modifying the base payload source.

Modular Route Contract

One payload, more than one module

The route stays declared once, but extra request fields can be composed in by another module through a typed trait instead of a fork.

The base module owns the route path and the main transport contract.

A second module adds its own request concerns, such as tracking or preview flags, with #[AsPayloadPart] on a trait, and the trait can own the setter-level guards for those fields.

Module A and Module B handlers now receive the same composed payload instance, so both can read the mixed contract without a payload fork.

Concern Without payload parts With #[AsPayloadPart]
Base route ownership Fork or reopen the original payload class Base payload stays untouched
Module-specific query fields Ad-hoc arrays or handler glue Typed trait methods on the same payload
Handler input Scattered conditionals One composed payload DTO

The important part is not the trait itself. The important part is that both modules still work with one payload boundary after composition, while each added field keeps its own normalization and guard logic.

#[AsPayloadPart] PayloadFactory trait composition module extension field-level guards

Verified against Semitexa Ultimate 2026.09.19.1020

Payload Parts

A payload can be extended by another module without reopening the original route class, so one transport boundary can stay singular while modules stay additive.

How it works

A base module declares the payload with one of the access attributes (#[AsPublicPayload], #[AsProtectedPayload], or #[AsServicePayload]). Another module contributes a trait marked with #[AsPayloadPart(base: ...)]. At runtime PayloadFactory composes a wrapper class that extends the base payload and uses all matching traits, so the added trait can own setters and guards for its own extra fields.

Why this matters

This solves a painful modularity problem: extra request concerns do not force a fork of the original payload and do not leak into untyped arrays. The handler still receives one trusted DTO, and each added concern can validate its own field without a central validate() choke point.

© Edsger W. Dijkstra: "Simplicity is prerequisite for reliability."

Base Payload Trusted input boundary
<?phpdeclare(strict_types=1);namespace App\Catalog\Application\Payload\Request;use Semitexa\Core\Attribute\AsPublicPayload;use Semitexa\Core\Exception\ValidationException;#[AsPublicPayload(    path: '/search',    methods: ['GET'],    responseWith: SearchPageResource::class,)]final class SearchPayload{    protected string $query = '';    public function getQuery(): string    {        return $this->query;    }    public function setQuery(string $query): void    {        $query = trim($query);        if ($query === '') {            throw new ValidationException(['query' => ['Search query is required.']]);        }        if (strlen($query) > 120) {            throw new ValidationException(['query' => ['Search query must stay below 120 characters.']]);        }        $this->query = $query;    }}

How it works

A base module declares the payload with #[AsPayload]. Another module contributes a trait marked with #[AsPayloadPart(base: ...)]. At runtime PayloadFactory composes a wrapper class that extends the base payload and uses all matching traits, so the added trait can own setters and guards for its own extra fields.

Why it matters

This solves a painful modularity problem: extra request concerns do not force a fork of the original payload and do not leak into untyped arrays. The handler still receives one trusted DTO, and each added concern can validate its own field without a central validate() choke point.

Key concepts

#[AsPayloadPart]
Marks a trait as an additive extension of an existing payload class.
PayloadFactory
Builds the runtime wrapper class that extends the base payload and mixes in discovered payload-part traits.
trait composition
Lets separate modules add typed setters, getters, and local guards to the same request boundary without modifying the base payload source.

Support Semitexa
Built for developers who prefer control over magic. Your support helps keep it fast, open, and evolving.

Donate via PayPal