Search by

sirix / mezzio-valinor-request-mapper

sirix777

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

Fund package maintenance!

sirix777

buymeacoffee.com/sirix

Statistics

Installs: 694

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

3.0.0 2026-09-12 16:21 UTC

This package is auto-updated.

Last update: 2026-09-12 16:22:38 UTC


README

Latest Stable Version Total Downloads Latest Unstable Version License PHP Version Require

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)
  • 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 422 response contract

Requirements

  • PHP ~8.2 || ~8.3 || ~8.4 || ~8.5
  • cuyz/valinor ^2.4
  • mezzio/mezzio-router ^3.15 || ^4.1
  • PSR-17 ResponseFactoryInterface and StreamFactoryInterface services
  • 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 MapperBuilder, TreeMapper, default responder, middleware, and the internal services that discover route metadata, build mapping plans, and construct Valinor HTTP input. Obtain the middleware from the container; manually constructing it requires all four of its dependencies and is intended only for custom integration.

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)

Run the application's body parser first, then route matching, then this middleware, and finally dispatch. A body parser (for example Mezzio's BodyParamsMiddleware) is an application dependency: this package neither installs nor configures one.

$app->pipe(\Mezzio\Middleware\BodyParamsMiddleware::class);
$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 the actually callable target. Class-level attributes run first, then method-level attributes on the selected method, in PHP declaration order.

Supported route handler targets:

Input Resolved method Notes
RequestHandlerInterface instance or FQCN handle
MiddlewareInterface instance or FQCN process Priority over RequestHandlerInterface when both are implemented
Invokable object __invoke Only when no PSR-15 interface is implemented
RequestHandlerMiddleware wrapper handle of the inner handler Ignores extra interfaces of the inner handler
CallableMiddlewareDecorator with array callable exact array method
CallableMiddlewareDecorator with first-class callable real method of the closure scope
CallableMiddlewareDecorator with invokable object __invoke
CallableMiddlewareDecorator with string Class::method Class and method if callable is valid
CallableMiddlewareDecorator with anonymous closure/function (no mapping) Attributes of the outer scope are not read
LazyLoadingMiddleware with handler FQCN handle or process Chosen by the declared FQCN's interfaces; unknown classes are ignored
MiddlewarePipe / pipeline (no mapping) Use explicit route options (see below)
alias / non-class service name (no mapping) Use explicit route options (see below)

For aliases and pipelines the package cannot discover attributes. Provide them explicitly via route options:

$route = $app->get('/orders', 'order.handler.alias');
$route->setOptions([
    'valinor_mappings' => [
        [
            'body' => CreateOrderRequest::class,
            'output' => 'createOrder',
            'methods' => ['POST'],
        ],
    ],
]);

Any non-empty valinor_mappings value takes precedence over reflection and must be a list of maps using only body, query, route, source, output, methods, and errorResponder. An empty list ([]) or a missing key falls back to reflection. A non-empty invalid payload raises InvalidMapRequestConfiguration; it is a configuration failure, not a 422 mapping response.

Discovery reflects the declared route handler, not the service instance the container may return. If you register a service under an FQCN but the container returns a different class, attributes of the declared FQCN are used. To map attributes of the actual instance, provide valinor_mappings explicitly.

2) With sirix/mezzio-routing-attributes

If your app uses sirix/mezzio-routing-attributes ^1.4 and it scans/collects route attribute modifiers, MapRequest is discovered as an AggregatingRouteAttributeModifierInterface implementation. Class- and method-level mappings accumulate in declaration order, while ValinorRequestMapperMiddleware is attached once to each matching route.

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

final readonly class MapRequest
{
    public function __construct(
        ?string $body = null,           // DTO class from parsed body
        ?string $query = null,          // DTO class from query parameters
        ?string $route = null,          // DTO class from route parameters
        ?string $source = null,         // DTO class using all HTTP sources
        ?string $output = null,         // request attribute key; defaults to DTO class
        array $methods = [],            // HTTP method filter
        ?string $errorResponder = null, // responder service class
    ) {}
}

Rules:

  • at least one source is required; source is mutually exclusive with body/query/route
  • source, output, and responder strings must be non-empty and have no leading or trailing whitespace
  • if output is omitted, mapped DTO is stored under its class name
  • methods = [] means any HTTP method
  • non-empty methods must be a list of non-empty HTTP tokens; they are normalized (post, Post -> POST)
  • errorResponder is 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
  • active operations must have distinct effective output keys. For example, two sources that map the same DTO need separate output values; the package no longer lets a later operation overwrite an earlier DTO.

For example, this is valid because the output keys differ:

#[MapRequest(query: PaginationRequest::class, output: 'pagination')]
#[MapRequest(body: CreateOrderRequest::class, output: 'createOrder')]
final class OrdersHandler implements RequestHandlerInterface
{
    // ...
}

HTTP input contract

Only the source selected by an operation is read. Body parsing and input validation happen before Valinor receives the data; the original PSR-7 request is never changed.

Mapping source Data supplied to Valinor Preconditions
body ServerRequestInterface::getParsedBody() must be array or null (null becomes an empty array)
query getQueryParams() parsed body is not read
route matched route parameters parsed body is not read
source route, query, and body body must be array or null; use explicit FromBody, FromQuery, and FromRoute attributes

String keys and values in each selected source, including nested native arrays, must be valid UTF-8. Invalid input produces a safe RequestInputError and the default 422 response; it is not repaired or passed to Valinor. Binary payloads belong in uploaded files or application-specific middleware instead.

With source, a constructor argument without a From* attribute is ambiguous when the same field exists in more than one HTTP source. Valinor reports that collision as a mapping error; specify the source explicitly rather than relying on an implicit priority.

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. For HTTP request mapping, extra top-level keys in body/query/route are still ignored; this flag primarily affects direct array mapping through the registered TreeMapper
allow_scalar_value_casting bool true Allow automatic scalar type casting (e.g. int → string). For HTTP mapping, strings from query/route parameters are always cast to target scalar types; this flag mainly controls body/array mapping behavior
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 appended to those already supported by the configured builder. If no configurator replaces them, Valinor's default RFC 3339 / timestamp formats are preserved; otherwise the configurator's list is the base

Cache

When cache_dir is set, Valinor caches compiled reflection data for mapped DTO types. This package separately keeps only handler and mapping metadata in memory. Under PHP-FPM that metadata lives for one request; persistent workers reuse it while their WeakMap entries can be released with routes and wrappers. Changing loaded PHP attributes requires a worker restart; cache_watch is a Valinor file-cache watcher, not an attribute watcher.

  • Production: set cache_dir and leave cache_watch disabled (default)
  • Development: set cache_watch: true so cache invalidates automatically when PHP files change

To pre-warm the cache during deployment, use the same MapperBuilder service that runtime uses, so the cache keys match exactly:

$mapperBuilder = $container->get(\CuyZ\Valinor\MapperBuilder::class);

$mapperBuilder->warmupCacheFor(
    \App\Domain\CreateUserRequest::class,
    \App\Domain\PaginationRequest::class,
    // ...
);

$container is the application container already configured with this package's ConfigProvider. Warm up with the same PHP version, configuration, and installed dependencies that the deployed runtime will use, and update the cache directory together with each release. When cache_watch is disabled (the production default), the cache is not invalidated automatically when PHP files change.

This warmup example applies to the mapper built from the registered MapperBuilder service. If your application overrides TreeMapper::class with a custom implementation, that mapper may use a different builder or no builder at all; the package does not guarantee cache-key compatibility with arbitrary TreeMapper overrides.

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 whose error is either Valinor MappingError or this package's RequestInputError, plus the current PSR-7 request, MapRequest, DTO class, mapping source (body, query, route, or source), and request attribute key. For RequestInputError, inspect reason and inputSource instead of calling Valinor's messages().

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 CuyZ\Valinor\Mapper\MappingError;
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 Sirix\Mezzio\Valinor\Error\RequestInputError;

final class ProblemDetailsResponder implements MappingErrorResponderInterface
{
    public function __construct(
        private ResponseFactoryInterface $responseFactory,
        private StreamFactoryInterface $streamFactory,
    ) {}

    public function respond(MappingErrorContext $context): ResponseInterface
    {
        if ($context->error instanceof RequestInputError) {
            $errors = [[
                'code' => $context->error->reason,
                'detail' => $context->error->getMessage(),
                'source' => $context->error->inputSource,
            ]];
        } else {
            /** @var MappingError $error */
            $error = $context->error;
            $errors = array_map(
                static fn ($message): array => [
                    'code' => $message->code(),
                    'detail' => (string) $message,
                ],
                [...$error->messages()],
            );
        }

        $body = json_encode([
            'type' => 'https://example.test/problems/validation-error',
            'title' => 'Validation failed',
            'status' => StatusCodeInterface::STATUS_UNPROCESSABLE_ENTITY,
            'errors' => $errors,
        ], 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, including for RequestInputError. To change the response contract, register an implementation of MappingErrorResponderInterface. See the 3.0 migration guide; applications upgrading from 1.x should first follow the 2.0 guide.

Notes and caveats

  • Middleware requires RouteResult attribute (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.
  • Parsed body values other than array or null return a RequestInputError before mapping. Other type or validation failures remain Valinor mapping errors.
  • 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