sirix/mezzio-valinor-request-mapper

Transparently maps PSR-7 requests to typed DTOs via cuyz/valinor in Mezzio applications

Maintainers

Package info

github.com/sirix777/mezzio-valinor-request-mapper

pkg:composer/sirix/mezzio-valinor-request-mapper

Transparency log

Fund package maintenance!

sirix777

buymeacoffee.com/sirix

Statistics

Installs: 652

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

2.0.0 2026-08-28 11:32 UTC

This package is auto-updated.

Last update: 2026-08-28 11:34:12 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.0
  • 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 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:

  • source is mutually exclusive with body/query/route
  • if output is omitted, mapped DTO is stored under its class name
  • methods = [] means any HTTP method
  • methods 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

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_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 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 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.
  • 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