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
- laminas/laminas-diactoros: ^3.5.0
- mezzio/mezzio: ^3.20
- mezzio/mezzio-router: ^3.15 || ^4.1
- psr/container: ^1.0 || ^2.0
- psr/http-server-middleware: ^1.0
- sirix/mezzio-routing-contracts: ^0.1
Requires (Dev)
- bamarni/composer-bin-plugin: ^1.9
- 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.
Pre-1.0 package: Not yet production-ready. Public API and configuration may change with breaking changes before
1.0.0.
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) - JSON error responses on mapping failures
- Optional Valinor error message remapping (
message_map)
Requirements
- PHP
~8.2 || ~8.3 || ~8.4 || ~8.5 cuyz/valinor ^2.4mezzio/mezzio ^3.2
Installation
composer require sirix/mezzio-valinor-request-mapper
The package auto-registers its config provider via Composer extra config. If your app does not use laminas-config-aggregator, add manually:
\Sirix\Mezzio\Valinor\ConfigProvider::class,
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);
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 );
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)- if multiple
#[MapRequest]attributes match current method, all of them are applied in declaration order
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'], ], 'error' => [ 'status_code' => 422, 'key_case' => null, // null|'snake_case' 'message_map' => [ // 'Value {source_value} is not a valid string.' => 'This field is required.', ], ], ], ];
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 options
| Option | Type | Default | Description |
|---|---|---|---|
status_code |
int |
422 |
HTTP status code for mapping error responses |
key_case |
null|'snake_case' |
null |
Transform error path keys to snake_case |
message_map |
array<string, string> |
[] |
Remap Valinor error messages by pattern match |
Error response format
On mapping failure middleware returns JSON:
{
"error": "Mapping failed",
"messages": {
"field": ["...message..."]
}
}
Status code defaults to 422 and can be overridden by sirix_mezzio_valinor.error.status_code.
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.