The bundle provides automatic mapping of HTTP request values to controller arguments using attributes. This feature leverages Valinor's mapping capabilities to handle route parameters, query values, and request body data.
Lean more about HTTP request mapping in the library documentation.
Note that Symfony provides a similar built-in solution, which makes use of
attributes like #[MapQueryString] and #[MapRequestPayload]. This bundle can
bring some additional features:
non-empty-string, positive-int,
int<10, 100> and more.Using the #[MapRequest] on a controller's method enables automatic
mapping.
Arguments can be mapped from different sources:
#[FromRoute] attribute#[FromQuery] attribute#[FromBody] attributeBasic example:
use CuyZ\Valinor\Mapper\Http\FromQuery;
use CuyZ\Valinor\Mapper\Http\FromRoute;
use CuyZ\ValinorBundle\Http\MapRequest;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
#[AsController]
final class ListArticles
{
/**
* GET /api/authors/{authorId}/articles?status=X&page=X&limit=X
*
* [@param](https://github.com/param) positive-int $page
* [@param](https://github.com/param) int<10, 100> $limit
*/
#[Route('/api/authors/{authorId}/articles', methods: 'GET')]
#[MapRequest]
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,
): Response { /* … */ }
}
You can customize the mapper behavior for a specific controller by
passing mapper configurators to the #[MapRequest] attribute:
use CuyZ\Valinor\Mapper\Configurator\ConvertKeysToCamelCase;
use CuyZ\Valinor\Mapper\Http\FromBody;
use CuyZ\ValinorBundle\Http\MapRequest;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
#[AsController]
final class CreateAuthor
{
#[Route('/api/authors/new', methods: 'POST')]
#[MapRequest(new ConvertKeysToCamelCase())]
public function __invoke(
#[FromBody] string $name,
#[FromBody] DateTimeInterface $birthDate,
): Response { /* … */ }
}
APIs often need to define rules concerning the keys cases passed in the request; this can be defined using the following configurators:
camelCase, PascalCase, snake_case or kebab-case.camelCase or snake_case.When multiple controllers share the same mapper configuration (date formats, key case rules, etc.), a custom attribute can be created to avoid repeating the same configurators on every controller.
This is done by implementing the MapRequestAttribute interface
directly:
use Attribute;
use CuyZ\Valinor\Mapper\Configurator\ConvertKeysToCamelCase;
use CuyZ\Valinor\Mapper\Configurator\RestrictKeysToSnakeCase;
use CuyZ\Valinor\MapperBuilder;
use CuyZ\ValinorBundle\Http\MapRequestAttribute;
#[Attribute(Attribute::TARGET_METHOD)]
final class MyAppMapRequest implements MapRequestAttribute
{
public function __construct(
/** [@var](https://github.com/var) list<non-empty-string> */
private array $dateFormats = ['Y-m-d', 'Y-m-d H:i:s'],
private bool $allowSuperfluousKeys = false,
) {}
public function configureMapperBuilder(MapperBuilder $builder): MapperBuilder
{
$builder = $builder->configureWith(
// Always restrict keys to `snake_case`
new RestrictKeysToSnakeCase(),
// Always convert keys to `camelCase`
new ConvertKeysToCamelCase(),
);
$builder = $builder->supportDateFormats(...$this->dateFormats);
if ($this->allowSuperfluousKeys) {
$builder = $builder->allowSuperfluousKeys();
}
return $builder;
}
}
It can then be used in place of #[MapRequest] on any controller
method:
use CuyZ\Valinor\Mapper\Http\FromBody;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
#[AsController]
final class CreateComment
{
#[Route('/api/comments', methods: 'POST')]
#[MyAppMapRequest(
dateFormats: ['d/m/Y'],
allowSuperfluousKeys: true,
)]
public function __invoke(
#[FromBody] string $author,
#[FromBody] string $content,
): Response { /* … */ }
}
When mapping fails, the bundle throws an HttpRequestMappingError
exception with a 422 Unprocessable Entity status code. The error
message includes all validation errors. Example:
HTTP request is invalid, a total of 2 error(s) were found:
- page: value 0 is not a valid positive integer.
- limit: value 150 is not a valid integer between 10 and 100.
Instead of mapping individual query parameters or body values to
separate parameters, the mapAll 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;
use CuyZ\ValinorBundle\Http\MapRequest;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
final readonly class ArticleFilters
{
public function __construct(
public string $status,
/** [@var](https://github.com/var) positive-int */
public int $page = 1,
/** [@var](https://github.com/var) int<10, 100> */
public int $limit = 10,
) {}
}
#[AsController]
final class ListArticles
{
/**
* GET /api/authors/{authorId}/articles?status=X&page=X&limit=X
*/
#[Route('/api/authors/{authorId}/articles', methods: 'GET')]
#[MapRequest]
public function __invoke(
#[FromRoute] string $authorId,
#[FromQuery(mapAll: true)] ArticleFilters $filters,
): Response { /* … */ }
}
The same approach works with #[FromBody(mapAll: true)] for body
values.
When a controller needs to access the original request object, it can be directly added as an argument:
use CuyZ\Valinor\Mapper\Http\FromRoute;
use CuyZ\ValinorBundle\Http\MapRequest;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
#[AsController]
final class ListArticles
{
#[Route('/api/authors/{authorId}/articles', methods: 'GET')]
#[MapRequest]
public function __invoke(
// Request object injected automatically
Request $request,
#[FromRoute] string $authorId,
): Response {
if ($request->headers->has('My-Customer-Header')) {
// …
}
}
}
Note — by enabling the valinor.http.convert_request_to_psr
configuration, controllers can type-hint a PSR-7
ServerRequestInterface parameter instead of Symfony's Request. The
bundle will automatically convert the incoming Symfony request to a
PSR-7 instance.
This requires the symfony/psr-http-message-bridge package to be
installed.
First stable release 🎉
Note that this release also includes a breaking change: the feature that allowed
attributes to configure a TreeMapper directly during the injection has been
removed.
After some thoughts, this implementation was a bad idea that led to more complexity in the bundle code base, for something that should anyway be done differently.
There will be no replacement for this feature, and code that used it should
instead either inject an instance of MapperBuilder or use the
MapperBuilderConfigurator interface.
MapperBuilderConfiguratorAttribute support (ddcf2f)ArrayNormalizer and JsonNormalizer as services (dcfd1c)Initial release 🎉
How can I help you explore Laravel packages today?