mcc-api-tools/request-object-bundle
A Symfony bundle for turning incoming HTTP requests into typed request objects. Centralize validation, mapping, and input handling so controllers stay clean, reusable, and easier to test across API endpoints and request workflows.
This is a Symfony bundle, not a Laravel package — there is no service provider, no config/request-object.php, and no facade. If you are coming from Laravel, the closest mental model is FormRequest minus the authorization/validation pipeline: a typed object that represents one request's input and gets hydrated before your controller runs. Treat the first hour as a source-reading exercise rather than a docs exercise.
Install and register:
composer require mcc-api-tools/request-object-bundle
With Symfony Flex the bundle should be auto-registered in config/bundles.php; otherwise add it manually. Confirm there is nothing to configure by checking DependencyInjection/Configuration.php — with a package this small, the "config" is usually just a couple of booleans (e.g. enabling an argument resolver).
Where to look first, in order:
src/ for the base RequestObject class or interface — this tells you whether hydration is constructor-based, setter-based, or property-reflection-based. That single decision dictates every pattern below.ArgumentResolver or event subscriber — this is the actual integration point with Symfony's HTTP kernel. If it exists, controller injection "just works"; if not, you instantiate the object yourself from $request.First use case: take one existing endpoint that does $request->query->get('page'), $request->request->get('email'), and a handful of manual casts, and replace it with a single typed object. Something like:
final class CreateUserRequest extends RequestObject
{
public function __construct(
public readonly string $email,
public readonly string $name,
public readonly bool $isAdmin = false,
) {}
}
// Controller
public function create(CreateUserRequest $input): Response
If the resolver is wired, that is the entire change. If it is not, the fallback is one line in the controller (CreateUserRequest::fromRequest($request) or similar) — verify the factory method name in the base class before assuming.
One object per endpoint, not per entity. The value of this pattern collapses if you try to build a shared UserRequest reused across create/update/filter. Each controller action should get its own small, final, readonly object. Duplication here is cheaper than conditional hydration.
Constructor promotion plus readonly is the happy path. If the bundle hydrates via constructor reflection, promoted readonly properties give you immutability for free and make the object trivially unit-testable (new CreateUserRequest('[email protected]', 'Alice') in a test, no HTTP involved). If hydration is setter-based, prefer private setters plus a fromArray factory rather than exposing mutable public properties.
Push validation into Symfony Validator constraints. The pattern only pays off when the object is the single source of truth for both shape and rules:
final class CreateUserRequest extends RequestObject
{
public function __construct(
#[Assert\NotBlank]
#[Assert\Email]
public readonly string $email,
#[Assert\Length(min: 2, max: 100)]
public readonly string $name,
) {}
}
Then rely on the bundle (or #[MapRequestPayload] / your own resolver) to throw a 422 before the controller body executes. If the bundle does not run the validator itself, this is your first extension point — wrap the resolver or add a kernel listener.
Use it as the boundary, then hand off plain values. Controllers should do $service->create($input->email, $input->name) or, if you prefer, pass the object into an application-layer DTO. Do not let the request object leak into your domain or entities — it is an HTTP concern and should stay in the HTTP layer.
Nested objects and collections. For JSON APIs, expect the interesting behavior to be in nested hydration: does an array of objects map to array<ItemRequest>, and does the bundle need a type hint, a docblock, or an attribute to know that? Test this explicitly with a POST containing a two-element array before committing to the pattern for complex payloads.
Testing workflow. Because the objects are plain classes, the fast tests are pure unit tests of hydration and constraints. Keep one thin functional test per endpoint asserting that a malformed body returns 422 and a valid one reaches the controller — that is where resolver misconfiguration shows up, and it is not something unit tests will catch.
Integration tip for mixed content types. If your app handles both application/json and application/x-www-form-urlencoded, confirm the bundle reads from $request->request vs $request->getContent() correctly for each. A common failure mode is a resolver that only handles one, silently producing an all-null object for the other.
The biggest gotcha is that this is not Laravel. There is no Illuminate\Foundation\Http\FormRequest equivalent here, and dropping it into a Laravel app means writing your own binding in a service provider or, more realistically, using spatie/laravel-data instead. Decide early whether you are actually adopting this package or just borrowing its pattern — the pattern is worth copying even if the package is not.
Adoption risk is real. One star, zero dependents, and a single-maintainer history means no community answers when something breaks. Pin the version in composer.json, commit the lock file, and read the resolver source before you depend on it in a critical path. Budget for the possibility of vendoring or forking it — at this size, that is a few hundred lines, not a crisis.
Release date vs. stability. The last release is dated 2026-07-07, so it is actively touched, but verify the supported Symfony range in composer.json against your actual version. A bundle built for Symfony 6 argument resolvers can behave differently under 7, particularly around controller argument priority and #[MapRequestPayload] precedence.
Resolver conflicts. If your app already uses #[MapRequestPayload], sensio/framework-extra-bundle converters, or a custom ValueResolver, ordering matters. When a typed controller argument unexpectedly arrives null or unhydrated, the cause is almost always another resolver claiming the argument first. Check debug:event-dispatcher / the resolver list, and set priority explicitly if the bundle exposes it.
Type coercion is where bugs hide. "false" from a form post is not false; "0" is falsy in PHP but a valid string; empty query params arrive as "" not null. Test the ugly inputs — empty strings, missing keys, arrays where scalars are expected — and decide whether missing optional values become null or trigger a 422. Document that decision in the request object itself with defaults.
Mass assignment. Request objects are a security boundary only if they are closed. If the object has public settable properties and the hydrator loops over all input keys, an attacker can set fields you did not intend. Prefer constructor parameters (which constrain the surface to declared arguments) and never hydrate from arbitrary arrays without filtering.
Debugging approach. When hydration fails silently, dump() inside the resolver (or a one-off dd of $request->getContent() at the top of the controller) beats guessing. Also check var/log/dev.log for validator violations — constraint failures often get swallowed into a generic 400 if the exception listener is not mapped to a 422.
Extension points worth knowing. The three places you will likely customize: (1) a custom value resolver if the built-in one does not cover a source you need (headers, route params, uploaded files); (2) a validation group strategy if create/update objects share constraints; (3) a normalizer or denormalizer if you need special scalar handling (enums, UUIDs, DateTimeImmutable). All three are normal Symfony seams — the bundle does not lock you out, which is its main virtue at this maturity level.
Verdict for day-to-day use. Adopt the pattern confidently; adopt the dependency cautiously. For a small team already on Symfony, it removes a class of $request->get() boilerplate for near-zero cost. For a Laravel codebase, skip the package and reach for spatie/laravel-data, which gives you the same typed-boundary ergonomics with a maintainer base you can rely on.
How can I help you explore Laravel packages today?