nelexa/request-dto-bundle
Symfony bundle for mapping HTTP request data into typed DTOs with attribute-based configuration, validation, and automatic hydration. Reduce controller boilerplate by turning query, body, and route parameters into clean, testable request objects, with support for nested DTOs and Symfony Validator.
First, the framework check. This is a Symfony bundle (nelexa/request-dto-bundle) that hooks into Symfony's HttpKernel argument-resolver chain. It has no Laravel equivalent and will not boot in a Laravel app — composer require will pull symfony/http-kernel, symfony/dependency-injection, and symfony/validator constraints that conflict with a Laravel install, and even if it installed, there is no ArgumentResolver seam in Laravel's router. If you are assessing this for a Laravel codebase, the honest verdict is: steal the pattern, not the package. The rest of this section describes the pattern, and the Symfony specifics only matter if you're actually in a Symfony app.
Where to look first (Symfony):
composer.json — check php and symfony/* constraints against your app. A 2021-12-08 release means PHP 8.2/8.3 and Symfony 6.4/7 are unlikely to be declared compatible.controller.argument_value_resolver tag). Read its supports() and resolve() methods — that's where hydration source and validation behaviour are decided. Run bin/console debug:container --tag=controller.argument_value_resolver to see where it sits in the priority order.Minimal first use case (the shape you're aiming for):
final class CreateUserRequest
{
/** @Assert\NotBlank */
public string $name;
/** @Assert\NotBlank @Assert\Email */
public string $email;
}
// Controller — no manual $request->get() calls, no validation boilerplate
public function create(CreateUserRequest $dto): Response
{
$this->handler->__invoke($dto);
return $this->json(['id' => ...], 201);
}
The Laravel equivalent you'd actually write is a FormRequest that owns its own mapping:
final class CreateUserRequest extends FormRequest
{
public function rules(): array
{
return ['name' => ['required','string'], 'email' => ['required','email']];
}
public function toDto(): CreateUserData
{
return new CreateUserData(...$this->validated());
}
}
Or, if you want the type-first ergonomics this package is imitating, spatie/laravel-data (CreateUserData::from($request)) gives you nested DTOs, casting, and validation in one class — strictly more capability than this bundle offers, actively maintained, and Laravel-native.
One DTO per action, grouped by resource. CreateUserRequest, UpdateUserRequest, ListUsersQuery — not one fat UserRequest reused across verbs, because GET filters and POST bodies have different sources and different validation groups.
Separate the input source from the DTO class. The useful discipline this package enforces is that a controller action declares what it needs, not how the HTTP request is shaped. Once you're writing DTOs, keep them framework-agnostic — plain classes with public typed properties and validator metadata, no Request injection, no Response knowledge. That single decision is what makes the code portable when you inevitably move off the bundle (see Gotchas).
Wire the DTO into a handler, not into an entity. The DTO is the boundary object between the HTTP layer and the application layer:
public function create(CreateUserRequest $dto): Response
{
$user = $this->createUser->__invoke($dto); // handler takes the DTO
return $this->json(UserResource::from($user), 201);
}
Never pass the DTO straight to $entity->fill() / a Doctrine hydrator — that's how a role field you didn't mean to expose becomes an escalation.
Validation belongs on the DTO, exception mapping belongs in one place. Put Symfony Validator constraints on the properties (or Laravel rules, or attributes) and register a single kernel.exception listener / Handler that turns ValidationFailedException into a 422 problem-details JSON body. If each controller catches its own validation exception, you've just moved the boilerplate, not removed it.
Test DTOs in isolation. Because the DTO has no HTTP coupling, you can unit-test it with the Validator directly — no kernel boot, no HTTP client:
$violations = $this->validator->validate(new CreateUserRequest(...));
self::assertCount(0, $violations);
Then one functional test per endpoint asserting the 422 payload shape (field key → message), because clients depend on that contract more than on the happy path.
For the Laravel-side equivalent, the workflow that scales is: DTO in app/Data, FormRequest::toDto() doing the mapping, controller injection via method type-hint, and the DTO passed to an Action/Command. That gives you the same "controller signatures read like the API" benefit without a third-party resolver.
This package is effectively superseded. Symfony 6.3+ ships native #[MapRequestPayload] and #[MapQueryString] attributes that do exactly this job, with first-party support and security updates. Given 9 stars, 0 dependents, and a single 2021 release, adopting nelexa/request-dto-bundle for new work in 2025 is a hard no. If you inherit it, the migration is genuinely cheap — the DTO classes survive; you delete the bundle and swap the resolver — provided you followed the "framework-agnostic DTO" advice above.
Bus factor 1, no patch path. Last release 2021-12-08, no dependents means nobody else is exercising the upgrade path. Expect composer constraint conflicts on PHP 8.2+ / Symfony 6.4+ and plan on forking or removing it. Don't let it become load-bearing in a long-lived codebase.
The 500-instead-of-422 trap. The single most common integration failure: validation throws, nothing converts it, and clients get a 500. Verify on day one what exception the resolver raises and confirm a listener maps it. Also confirm whether Accept: application/json vs text/html changes the response.
Uninitialized typed properties. A public string $name; that never gets hydrated throws Error: Typed property must not be accessed before initialization the moment the validator touches it — which reads as a cryptic 500. Either give every property a default (public string $name = '';) or make them nullable and validate for NotBlank separately. Test with an empty request body, not just a well-formed one.
Source precedence and content types. Route params, query string, JSON body, form body, and multipart files are different hydration paths. Verify (and document) what happens when the same key arrives in both the route and the body, and what an empty body with Content-Type: application/json does versus no content type at all. Multipart/file uploads are the path most likely to be untested in a 9-star package.
Resolver shadowing. Multiple bundles register controller.argument_value_resolver services (SensioFrameworkExtraBundle, native MapRequestPayload, this one). Priority order decides who wins, and debugging a DTO that silently arrives empty is almost always a priority collision. debug:container --tag=controller.argument_value_resolver is your first command, and a breakpoint in supports() is your second.
Extension points worth knowing. A custom supports() override lets you scope the resolver to a marker interface (e.g. RequestDtoInterface) so it stops trying to hydrate unrelated controller arguments. A custom name converter handles snake_case → camelCase. Validation groups let the same DTO serve create vs update. All three are cheap to add and save you from fighting the defaults.
Mass-assignment is the thing it gets right. Because only declared properties are hydrated, a DTO is a better input boundary than $request->all(). Keep that property — in the Laravel port, always use $request->validated() or an explicit DTO constructor, never $request->all().
How can I help you explore Laravel packages today?