sirix / mezzio-valinor-request-mapper
Transparently maps PSR-7 requests to typed DTOs via cuyz/valinor in Mezzio applications
Package info
github.com/sirix777/mezzio-valinor-request-mapper
pkg:composer/sirix/mezzio-valinor-request-mapper
Requires
- php: ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0
- cuyz/valinor: ^2.0
- fig/http-message-util: ^1.1
- mezzio/mezzio-router: ^3.15 || ^4.1
- psr/container: ^1.0 || ^2.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.0 || ^2.0
- psr/http-server-handler: ^1.0
- psr/http-server-middleware: ^1.0
- sirix/container-resolver: ^1.0
- sirix/mezzio-routing-contracts: ^1.0
Requires (Dev)
- bamarni/composer-bin-plugin: ^1.9
- laminas/laminas-diactoros: ^3.5.0
- laminas/laminas-stratigility: ^4.3
- mezzio/mezzio: ^3.20
- phpunit/phpunit: ^11.5
Suggests
- sirix/mezzio-routing-attributes: Enables automatic per-route middleware registration for #[MapRequest] via attribute scanning
README
Typed request mapping for Mezzio handlers via cuyz/valinor.
This package reads #[MapRequest] attributes on route handlers and maps request input (body/query/route) into DTOs before your handler code runs.
Features
#[MapRequest]attribute for class and method targets (repeatable)- Mapping from:
- parsed body (
body) - query params (
query) - route params (
route) - combined HTTP request (
source) using Valinor HTTP attributes (FromBody,FromQuery,FromRoute)
- parsed body (
- Optional request attribute key override via
output - HTTP method filter via
methods(case-insensitive, normalized to uppercase) - Pluggable error responders for mapping failures
- Built-in JSON fallback with a fixed
422response contract
Requirements
- PHP
~8.2 || ~8.3 || ~8.4 || ~8.5 cuyz/valinor ^2.0mezzio/mezzio-router ^3.15 || ^4.1- PSR-17
ResponseFactoryInterfaceandStreamFactoryInterfaceservices sirix/mezzio-routing-contracts ^1.0
The package targets Mezzio applications, but does not install a specific Mezzio, PSR-7, or PSR-17 implementation. Your application provides those runtime dependencies; the default responder uses its PSR-17 factories.
Installation
composer require sirix/mezzio-valinor-request-mapper
Required service registration
ConfigProvider is required. It registers the TreeMapper,
DefaultMappingErrorResponder, MappingErrorResponderResolver, and
ValinorRequestMapperMiddleware services.
In a standard Mezzio application it is discovered automatically by
laminas/laminas-component-installer.
If your application configures providers manually, add it to the
ConfigAggregator:
use Laminas\ConfigAggregator\ConfigAggregator; use Sirix\Mezzio\Valinor\ConfigProvider as ValinorRequestMapperConfigProvider; $aggregator = new ConfigAggregator([ ValinorRequestMapperConfigProvider::class, // other providers… ]);
Middleware registration modes
1) Standalone Mezzio (without sirix/mezzio-routing-attributes)
Register middleware globally after route matching and before dispatch:
$app->pipe(\Mezzio\Router\Middleware\RouteMiddleware::class); $app->pipe(\Sirix\Mezzio\Valinor\Middleware\ValinorRequestMapperMiddleware::class); $app->pipe(\Mezzio\Router\Middleware\DispatchMiddleware::class);
In standalone mode the middleware resolves #[MapRequest] by reflection from:
- class-level attributes on the matched route handler
- method-level attributes on PSR-15
process() - method-level attributes on request handler
handle()when Mezzio wraps it as route middleware - method-level attributes on invokable route middleware via
__invoke()
2) With sirix/mezzio-routing-attributes
If your app uses sirix/mezzio-routing-attributes and it scans/collects route attribute modifiers,
MapRequest is discovered as a RouteAttributeModifierInterface implementation and
ValinorRequestMapperMiddleware is attached to matching routes automatically.
In this mode you usually do not need to register
\Sirix\Mezzio\Valinor\Middleware\ValinorRequestMapperMiddleware::class
as a global pipeline middleware.
Example (class-level + method-level attributes):
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Sirix\Mezzio\Routing\Attributes\Attribute\Get; use Sirix\Mezzio\Routing\Attributes\Attribute\Post; use Sirix\Mezzio\Valinor\Attribute\MapRequest; final readonly class PaginationRequest { public function __construct(public int $page = 1) {} } final readonly class CreateOrderRequest { public function __construct(public string $name, public string $email) {} } #[MapRequest(query: PaginationRequest::class)] final class OrdersHandler { #[Get('/orders', name: 'orders.list')] public function list(ServerRequestInterface $request): ResponseInterface { $pagination = $request->getAttribute(PaginationRequest::class); // ... } #[Post('/orders', name: 'orders.create')] #[MapRequest(body: CreateOrderRequest::class, output: 'form')] public function create(ServerRequestInterface $request): ResponseInterface { $form = $request->getAttribute('form'); // ... } }
In this setup #[MapRequest] contributes route middleware via routing attribute processing,
so no extra global pipeline registration is required for the mapper middleware.
Quick start
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; use Sirix\Mezzio\Valinor\Attribute\MapRequest; final readonly class CreateUserRequest { public function __construct( public string $name, public string $email, ) {} } #[MapRequest(body: CreateUserRequest::class)] final class CreateUserHandler implements RequestHandlerInterface { public function handle(ServerRequestInterface $request): ResponseInterface { /** @var CreateUserRequest $dto */ $dto = $request->getAttribute(CreateUserRequest::class); // use $dto ... } }
Attribute API
new MapRequest( body: ?string, // class-string DTO from parsed body query: ?string, // class-string DTO from query params route: ?string, // class-string DTO from route params source: ?string, // class-string DTO from combined request sources output: ?string, // request attribute key, defaults to DTO FQCN methods: array, // HTTP methods filter errorResponder: ?string, // class-string<MappingErrorResponderInterface> );
Rules:
sourceis mutually exclusive withbody/query/route- if
outputis omitted, mapped DTO is stored under its class name methods = []means any HTTP methodmethodsare normalized (post,Post->POST)errorResponderis resolved only from the container when mapping fails; when it is not registered, the default responder is used- if multiple
#[MapRequest]attributes match current method, all of them are applied in declaration order; class-level mappings run before method-level mappings
Combined mapping (source)
Use Valinor HTTP source attributes in DTO constructor:
use CuyZ\Valinor\Mapper\Http\FromBody; use CuyZ\Valinor\Mapper\Http\FromQuery; use CuyZ\Valinor\Mapper\Http\FromRoute; final readonly class SearchRequest { public function __construct( #[FromRoute] public string $locale, #[FromQuery] public string $q, #[FromBody] public ?array $filters = null, ) {} } #[MapRequest(source: SearchRequest::class)] final class SearchHandler implements RequestHandlerInterface { public function handle(ServerRequestInterface $request): ResponseInterface { $dto = $request->getAttribute(SearchRequest::class); // ... } }
Configuration
Create config/autoload/mezzio-valinor.global.php:
<?php declare(strict_types=1); return [ 'sirix_mezzio_valinor' => [ 'mapper' => [ 'cache_dir' => __DIR__ . '/../../cache/valinor', 'cache_watch' => false, 'configurators' => [ \CuyZ\Valinor\Mapper\Configurator\ConvertKeysToCamelCase::class, ], 'allow_superfluous_keys' => true, 'allow_scalar_value_casting' => true, 'allow_permissive_types' => false, 'allow_undefined_values' => false, 'support_date_formats' => ['Y-m-d', 'd/m/Y'], ], ], ];
Mapper options
| Option | Type | Default | Description |
|---|---|---|---|
cache_dir |
?string |
null |
Path to cache directory. When set, Valinor caches compiled type metadata via FileSystemCache |
cache_watch |
bool |
false |
Wrap cache with FileWatchingCache to auto-invalidate when PHP files change (use in dev) |
configurators |
array<string|MapperBuilderConfigurator> |
[] |
Services or class-strings applied via configureWith() |
allow_superfluous_keys |
bool |
true |
Allow extra keys in input that are not mapped |
allow_scalar_value_casting |
bool |
true |
Allow automatic scalar type casting (e.g. int → string) |
allow_permissive_types |
bool |
false |
Allow mixed type to accept any value |
allow_undefined_values |
bool |
false |
Fill missing keys with null instead of failing |
support_date_formats |
list<string> |
[] |
Additional date formats for DateTimeInterface mapping |
Cache
When cache_dir is set, Valinor caches compiled reflection data for mapped DTO types, significantly reducing first-request latency.
- Production: set
cache_dirand leavecache_watchdisabled (default) - Development: set
cache_watch: trueso cache invalidates automatically when PHP files change
To pre-warm the cache during deployment, use a CLI script:
$mapperBuilder = (new \CuyZ\Valinor\MapperBuilder()) ->withCache(new \CuyZ\Valinor\Cache\FileSystemCache('path/to/cache-dir')); $mapperBuilder->warmupCacheFor( \App\Domain\CreateUserRequest::class, \App\Domain\PaginationRequest::class, // ... );
Mapper configurators
mapper.configurators supports:
- service id (resolved from container)
- class-string implementing
MapperBuilderConfigurator(instantiated if service not found)
Error responders
On a mapping failure, the middleware delegates to
MappingErrorResponderInterface. The responder receives a
MappingErrorContext with the Valinor MappingError, the current PSR-7
request, the MapRequest attribute, DTO class, mapping source (body,
query, route, or source), and the request attribute key.
The package registers MappingErrorResponderInterface to the built-in
DefaultMappingErrorResponder. Override that service in your container to
change the application-wide response contract:
use Fig\Http\Message\StatusCodeInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\StreamFactoryInterface; use Sirix\Mezzio\Valinor\Error\MappingErrorContext; use Sirix\Mezzio\Valinor\Error\MappingErrorResponderInterface; use function json_encode; use const JSON_THROW_ON_ERROR; final class ProblemDetailsResponder implements MappingErrorResponderInterface { public function __construct( private ResponseFactoryInterface $responseFactory, private StreamFactoryInterface $streamFactory, ) {} public function respond(MappingErrorContext $context): ResponseInterface { $body = json_encode([ 'type' => 'https://example.test/problems/validation-error', 'title' => 'Validation failed', 'status' => StatusCodeInterface::STATUS_UNPROCESSABLE_ENTITY, 'errors' => array_map( static fn ($message): array => [ 'code' => $message->code(), 'detail' => (string) $message, ], [...$context->error->messages()], ), ], JSON_THROW_ON_ERROR); return $this->responseFactory ->createResponse(StatusCodeInterface::STATUS_UNPROCESSABLE_ENTITY) ->withHeader('Content-Type', 'application/problem+json') ->withBody($this->streamFactory->createStream($body)); } }
Register ProblemDetailsResponder as the service for
MappingErrorResponderInterface. Responder classes need not be stateless: the
container can inject a translator, logger, response factory, or request-id
provider.
For a typical Mezzio application using laminas-servicemanager, register the concrete responder and alias the package interface to it in your application configuration:
use App\Error\ProblemDetailsResponder; use App\Factory\ProblemDetailsResponderFactory; use Sirix\Mezzio\Valinor\Error\MappingErrorResponderInterface; return [ 'dependencies' => [ 'factories' => [ ProblemDetailsResponder::class => ProblemDetailsResponderFactory::class, ], 'aliases' => [ MappingErrorResponderInterface::class => ProblemDetailsResponder::class, ], ], ];
ProblemDetailsResponderFactory is a conventional invokable factory that
receives the container and creates the responder with its dependencies.
Registering the concrete class is also required when it is referenced by a
per-mapping errorResponder attribute.
For a single mapping, use errorResponder on MapRequest and register that
class in the container. The middleware never instantiates this class-string;
if the service is absent, it safely falls back to the application-wide default.
#[MapRequest(body: CreateUserRequest::class, errorResponder: ProblemDetailsResponder::class)] final class CreateUserHandler implements RequestHandlerInterface { // ... }
Default error response and migration
When no custom application-wide responder is configured, the built-in
DefaultMappingErrorResponder registered by ConfigProvider is used. Its
response contract is fixed:
{
"error": "Mapping failed",
"messages": {
"field": ["...message..."]
}
}
It always returns status 422 and Content-Type: application/json. To change
the response contract, register an implementation of
MappingErrorResponderInterface. See the 2.0 migration guide
for constructor and configuration changes.
Notes and caveats
- Middleware requires
RouteResultattribute (it is a no-op when route is not matched yet). - With
sirix/mezzio-routing-attributes, middleware can be added per-route automatically via attribute scanning. - For body mapping, malformed body structures can still fail at Valinor level and return configured mapping error response.
- When using a custom
output, ensure downstream code reads the same request key.
Release checklist
Before tagging a stable release:
composer validate --strict composer normalize --dry-run --diff composer analyse-deps composer check