Mapping an HTTP request¶
This library provides a way to map an HTTP request to controller action parameters or object properties. Parameters can be mapped from route, query and body values.
Three attributes are available to explicitly bind a parameter to a single source, ensuring the value is never resolved from the wrong source:
#[FromRoute]— for parameters extracted from the URL path by a router#[FromQuery]— for query string parameters#[FromBody]— for request body values
Those attributes can be omitted entirely if the parameter is not bound to a specific source, in which case a collision error is raised if the same key is found in more than one source.
This gives controllers a clean, type-safe signature without coupling to a framework's request object, while benefiting from the library's validation and error handling.
Normal mapping rules apply there: parameters are required unless they have a default value.
Route and query parameter values coming from an HTTP request are typically
strings. The mapper automatically handles scalar value casting for these
parameters: a string "42" will be properly mapped to an int parameter.
Tip
The Valinor Symfony Bundle provides a native integration with Symfony's HTTP Foundation component.
Mapping a request using attributes¶
Consider an API that lists articles for a given author. The author identifier comes from the URL path, while filtering and pagination come from the query string.
use CuyZ\Valinor\Mapper\Http\FromQuery;
use CuyZ\Valinor\Mapper\Http\FromRoute;
use CuyZ\Valinor\Mapper\Http\HttpRequest;
use CuyZ\Valinor\MapperBuilder;
final class ListArticles
{
/**
* GET /api/authors/{authorId}/articles?status=X&page=X&limit=X
*
* @param non-empty-string $status
* @param positive-int $page
* @param int<10, 100> $limit
*/
public function __invoke(
// Comes from the route
#[FromRoute] string $authorId,
// All come from query parameters
#[FromQuery] string $status,
#[FromQuery] int $page = 1,
#[FromQuery] int $limit = 10,
): ResponseInterface { … }
}
// GET /api/authors/42/articles?status=published&page=2
$request = new HttpRequest(
routeParameters: ['authorId' => 42],
queryParameters: [
'status' => 'published',
'page' => 2,
],
);
$controller = new ListArticles();
$arguments = (new MapperBuilder())
->argumentsMapper()
->mapArguments($controller, $request);
$response = $controller(...$arguments);
Mapping a request without using attributes¶
When it is unnecessary to distinguish which source a parameter comes from, the attribute can be omitted entirely — the mapper will resolve each parameter from whichever source contains the matching key.
use CuyZ\Valinor\Mapper\Http\HttpRequest;
use CuyZ\Valinor\MapperBuilder;
final class PostComment
{
/**
* POST /api/posts/{postId}/comments
*
* @param positive-int $postId
* @param non-empty-string $author
* @param non-empty-string $content
*/
public function __invoke(
int $postId,
string $author,
string $content,
): ResponseInterface { … }
}
// POST /api/posts/1337/comments
$request = new HttpRequest(
routeParameters: ['postId' => 1337],
bodyValues: [
'author' => 'jane.doe@example.com',
'content' => 'Great article, thanks for sharing!',
],
);
$controller = new PostComment();
$arguments = (new MapperBuilder())
->argumentsMapper()
->mapArguments($controller, $request);
$response = $controller(...$arguments);
Note
If the same key is found in more than one source for a parameter that has no attribute, a collision error is raised.
Mapping all parameters at once¶
Instead of mapping individual query parameters or body values to separate
parameters, the asRoot option can be used to map all of them at once to a
single parameter. This is useful when working with complex data structures or
when the number of parameters is large.
use CuyZ\Valinor\Mapper\Http\FromQuery;
use CuyZ\Valinor\Mapper\Http\FromRoute;
final readonly class ArticleFilters
{
public function __construct(
/** @var non-empty-string */
public string $status,
/** @var positive-int */
public int $page = 1,
/** @var int<10, 100> */
public int $limit = 10,
) {}
}
final class ListArticles
{
/**
* GET /api/authors/{authorId}/articles?status=X&&page=X&limit=X
*/
public function __invoke(
#[FromRoute] string $authorId,
#[FromQuery(asRoot: true)] ArticleFilters $filters,
): ResponseInterface { … }
}
The same approach works with #[FromBody(asRoot: true)] for body values.
Tip
A shaped array can be used alongside asRoot to map all values to a single
parameter:
use CuyZ\Valinor\Mapper\Http\FromQuery;
use CuyZ\Valinor\Mapper\Http\FromRoute;
final class ListArticles
{
/**
* GET /api/authors/{authorId}/articles?status=X&&page=X&limit=X
*
* @param array{
* status: non-empty-string,
* page?: positive-int,
* limit?: int<10, 100>,
* } $filters
*/
public function __invoke(
#[FromRoute] string $authorId,
#[FromQuery(asRoot: true)] array $filters,
): ResponseInterface { … }
}
Mapping to an object¶
Instead of mapping to a callable's arguments, an HttpRequest can be mapped
directly to an object. The attributes work the same way on constructor
parameters or promoted properties.
use CuyZ\Valinor\Mapper\Http\FromBody;
use CuyZ\Valinor\Mapper\Http\FromRoute;
use CuyZ\Valinor\Mapper\Http\HttpRequest;
use CuyZ\Valinor\MapperBuilder;
final readonly class PostComment
{
public function __construct(
/** @var positive-int */
#[FromRoute] public int $postId,
/** @var non-empty-string */
#[FromBody] public string $author,
/** @var non-empty-string */
#[FromBody] public string $content,
) {}
}
$request = new HttpRequest(
routeParameters: ['postId' => 1337],
bodyValues: [
'author' => 'jane.doe@example.com',
'content' => 'Great article, thanks for sharing!',
],
);
$comment = (new MapperBuilder())
->mapper()
->map(PostComment::class, $request);
// $comment->postId === 1337
// $comment->author === 'jane.doe@example.com'
// $comment->content === 'Great article, thanks for sharing!'
Using PSR-7 requests¶
An HttpRequest instance can be built directly from a PSR-7
ServerRequestInterface. This is the recommended approach when integrating with
frameworks that use PSR-7.
use CuyZ\Valinor\Mapper\Http\HttpRequest;
use CuyZ\Valinor\MapperBuilder;
// `$psrRequest` is a PSR-7 `ServerRequestInterface` instance
// `$routeParameters` are the parameters extracted by the router
$request = HttpRequest::fromPsr($psrRequest, $routeParameters);
$arguments = (new MapperBuilder())
->argumentsMapper()
->mapArguments($controller, $request);
The factory method extracts query parameters from getQueryParams() and body
values from getParsedBody(). It also passes the original PSR-7 request object
through, so it can be injected into controller parameters if needed (see below).
Accessing the original request object¶
When building an HttpRequest, an original request object can be provided. If a
controller parameter's type matches this object, it will be injected
automatically; no attribute is needed.
use CuyZ\Valinor\Mapper\Http\FromRoute;
use CuyZ\Valinor\Mapper\Http\HttpRequest;
use CuyZ\Valinor\MapperBuilder;
use Psr\Http\Message\ServerRequestInterface;
final class ListArticles
{
/**
* GET /api/authors/{authorId}/articles
*/
public function __invoke(
// Request object injected automatically
ServerRequestInterface $request,
#[FromRoute] string $authorId,
): ResponseInterface {
$acceptHeader = $request->getHeaderLine('Accept');
// …
}
}
$request = HttpRequest::fromPsr($psrRequest, $routeParameters);
$arguments = (new MapperBuilder())
->argumentsMapper()
->mapArguments(new ListArticles(), $request);
// $arguments['request'] is the original PSR-7 request instance
Enforcing and converting key case¶
APIs often use a different naming convention than the PHP codebase — for
instance, a JSON payload with snake_case keys mapped to camelCase
properties. Two families of configurators help with this:
- RestrictKeysTo*Case configurators reject keys that do not match the
expected format (e.g.
snake_case,camelCase), raising a mapping error. - ConvertKeysTo*Case configurators convert input keys to the target format
before mapping (e.g.
first_name→firstName).
They can be combined so that the restriction validates the original keys and the conversion remaps them. Both work with HTTP request mapping.
use CuyZ\Valinor\Mapper\Configurator\ConvertKeysToCamelCase;
use CuyZ\Valinor\Mapper\Configurator\RestrictKeysToSnakeCase;
use CuyZ\Valinor\MapperBuilder;
$controllerArguments = (new MapperBuilder())
->configureWith(
new RestrictKeysToSnakeCase(),
new ConvertKeysToCamelCase(),
)
->argumentsMapper()
->mapArguments($controller, $httpRequest);
Read the common mapper configurators chapter for the full list of available configurators and detailed usage.
Error handling¶
When the mapping fails — for instance because a required query parameter is
missing or a body value has the wrong type — a MappingError is thrown, just
like with regular mapping.
Read the validation and error handling chapter for more information.