mops1k/request-object-resolver-bundle
A Symfony bundle that resolves typed request objects from incoming HTTP requests and injects them into controller arguments. It keeps controllers clean by moving request parsing, mapping, and validation into dedicated request classes for simpler, safer endpoints.
Reality check first: this is a Symfony bundle (mops1k/request-object-resolver-bundle), not a Laravel package. As a Laravel developer you cannot composer require it into an app/ and expect it to work — there is no service provider, no config/app.php entry, and it hooks into Symfony's ArgumentResolver / kernel.controller pipeline. If you are assessing it because you are moving into a Symfony codebase (or maintaining one alongside a Laravel app), read on — the mental model transfers almost 1:1 to Laravel FormRequests. If you are looking for the Laravel equivalent, stop reading and reach for FormRequest + $request->validated(), or spatie/laravel-data if you want typed DTOs.
Minimal steps (in a Symfony app):
composer require mops1k/request-object-resolver-bundle — you want v2.0.0 or later; v2 is the first release with attribute support and a pluggable resolver chain. Check the README for the exact bundle class name and attribute namespace before writing code; the package is small and docs are thin, so the source is your spec.config/bundles.php automatically).symfony/serializer and symfony/validator are installed — the bundle deserializes and validates through them.#[Assert\*] constraints.Where to look first:
ArgumentResolver class (usually under src/), because that is where deserialization, validation, and error handling actually happen.First use case: a POST /api/orders endpoint where you currently do Request $request and a pile of $request->request->get(...) / $request->toArray() calls. Replace it with one typed DTO parameter. That single swap is the entire value proposition: the controller signature becomes the contract.
#[Route('/api/orders', methods: ['POST'])]
public function create(
#[RequestObject] CreateOrderRequest $payload, // verify attribute name in README
): JsonResponse {
$order = $this->orders->create($payload);
return $this->json($order, 201);
}
final class CreateOrderRequest
{
public function __construct(
#[Assert\NotBlank, Assert\Email]
public readonly string $email,
#[Assert\Count(min: 1), Assert\Valid]
public readonly array $items,
) {}
}
DTO-as-contract, one per action. Resist reusing a single OrderRequest across create/update/delete. Separate CreateOrderRequest / UpdateOrderRequest classes make the validation rules and required-ness obvious at the call site. This is the same reason FormRequest subclasses are per-action in Laravel.
Push validation into the DTO, not the controller. Constraints as attributes on properties/constructor params mean validation runs as part of resolution and the controller never sees an invalid object. If the bundle surfaces failures as an HttpException (typically 400), you get a free fail-fast layer — no if ($validator->fails()) boilerplate.
Nested and collection payloads. For {"items": [{"sku": "A", "qty": 2}]}, define an OrderItemInput class and annotate the array with #[Assert\Valid]. Without Valid, nested objects are deserialized but never validated — a silent hole that ships to production surprisingly often.
Groups for shape-shifting payloads. If the same DTO serves a create (email required) and a patch (email optional), use serialization/validation groups on the attribute (e.g. #[RequestObject(groups: ['create'])]) rather than duplicating classes. Verify the exact option names against the attribute constructor — this is the one place the thin README is likely to under-document.
Custom resolvers — the v2 headline. The resolver chain is now extensible, which unlocks the pattern you actually want in a mature app: one controller signature style that reads from the right source per parameter. Typical custom resolvers:
OrderId or OrderFilter DTO from {id} / query string instead of the body, so GET /orders/{id} and POST /orders both use typed parameters.TenantContext from a header or the authenticated user, validated like any other DTO.EntityValueResolver-ish resolver but map onto your input type, keeping controllers free of findOrFail.Register yours with a tag and a priority so it runs before the generic body resolver — otherwise the body resolver claims the parameter first and hands you an empty object. Keep each resolver narrow and single-source; a resolver that inspects the request and guesses is much harder to debug than two resolvers with explicit ordering.
Composite payloads from multiple sources. With a chain in place you can split a request into #[RequestObject] CreateOrderRequest $body and OrderId $id on the same action, each resolved by a different link in the chain. This is where the bundle starts to genuinely beat hand-rolled $request->toArray().
Snake-case wire format. JSON APIs frequently use snake_case while PHP properties are camelCase. Configure a CamelCaseToSnakeCaseNameConverter on the serializer in config/packages/framework.yaml rather than naming PHP properties $first_name. Do it app-wide so DTOs stay idiomatic.
Testing is where the pattern pays off. Functional tests become short and declarative:
$client->request('POST', '/api/orders', server: ['CONTENT_TYPE' => 'application/json'], content: json_encode($body));
self::assertResponseStatusCodeSame(422);
Because the DTO is a plain class, unit tests can new CreateOrderRequest(...) and hand it to the handler directly — no HTTP layer, no mocks of Request. This is the biggest day-to-day win, and it's identical to why Laravel devs like ->validated() arrays being replaced by DTOs.
Integration tip / bridge pattern. If you genuinely maintain both stacks, keep the DTO classes in a framework-agnostic namespace (no Symfony or Laravel imports beyond validator attributes) and hydrate them from Laravel via a small FormRequest::toDto() method. That gives you one shared contract definition and two thin adapters, instead of two divergent request shapes.
#[MapRequestPayload] before adopting — the bundle's original "better alternative" pitch is now largely moot, and its main remaining edge is the pluggable chain.application/x-www-form-urlencoded) or multipart/form-data payloads will not populate the DTO the way you expect. File uploads need a different approach entirely.POST with {} and with no body at all before you ship; wire a kernel.exception listener if the default response shape isn't your API's error envelope.ALLOW_EXTRA_ATTRIBUTES default behavior matters: extra JSON fields either throw or are silently dropped. Decide deliberately — silently dropping is friendlier for clients but hides typos.true → bool works; "true" → bool depends on the denormalizer. "1" into an int is fine; "abc" into an int throws a NotNormalizableValueException that often surfaces as a 500 rather than a 422. Add a test for one bad-type payload per DTO.symfony/validator or a config flag. Confirm by deliberately violating a NotBlank in a test.php bin/console debug:event-dispatcher kernel.controller (or debug:container --tag=controller.argument_value_resolver) to confirm the resolver is registered and at what priority. With a resolver chain this is now the first place to look — dump the registered resolvers and their priorities, and check whether an earlier one is claiming your parameter. A DTO arriving as an empty object means a resolver ran; which one is the question.cache:clear, cache:warmup), otherwise the first request per DTO pays denormalization metadata cost.Denormalizer for value objects (Money, Uuid), a custom constraint + validator for cross-field rules (startDate < endDate — property-level attributes can't express that easily), a kernel.exception listener to normalize validation errors into {errors: {field: [...]}}, and now a custom resolver for non-body sources. Those four cover nearly every real-world gap.FormRequest::toDto() plus readonly constructor-promoted DTOs gets you 90% of the ergonomics with zero third-party risk. If you specifically want the resolver half (typed, validated parameters from body, route, headers), that maps onto Laravel's own route-model binding + a small ResolvesFromRequest concern.How can I help you explore Laravel packages today?