somnambulist/form-request-bundle
Symfony bundle bringing Laravel-style form requests to Symfony: encapsulate validation, authorization, and input handling in dedicated request classes that resolve automatically as controller arguments, keeping controllers thin and request logic reusable and testable.
Install and register. Pull the package with Composer and, unless a Flex recipe is available for your version, register the bundle manually:
composer require somnambulist/form-request-bundle
// config/bundles.php
return [
// ...
Somnambulist\Bundles\FormRequestBundle\FormRequestBundle::class => ['all' => true],
];
If the bundle is not registered, the argument resolver never runs and your type-hinted request will either fail to instantiate or be silently populated with no validation — the most common "why did nothing happen?" failure with this class of package.
Create your first request class. Extend the package's base FormRequest and implement rules() using Laravel-style pipe/dot rule strings. authorize() defaults to allowing the request; override it when you want policy checks colocated with validation:
namespace App\Request;
use Somnambulist\Bundles\FormRequestBundle\Http\FormRequest;
class CreateUserRequest extends FormRequest
{
public function rules(): array
{
return [
'email' => 'required|email|max:255',
'name' => 'required|string|max:255',
'password' => 'required|string|min:8|confirmed',
];
}
public function authorize(): bool
{
return $this->user()?->isAdmin() ?? false;
}
}
Wire it into a controller by type-hinting the request class as an action argument. The package resolves it from the current Symfony Request, hydrates inputs, runs authorize() then validation, and hands you a populated object (or aborts before your code runs):
public function store(CreateUserRequest $request): Response
{
$data = $request->validated(); // clean, whitelisted input
// dispatch a command / persist $data
}
First use case / verification. Ship one endpoint behind the request class, then hit it with (a) a valid JSON payload, (b) a missing field, and (c) an unauthorized user, and inspect the response for each. That single round-trip tells you how the package renders validation failures (422 JSON vs. redirect-with-errors), what an authorization failure looks like, and whether confirmed-style rules behave as you expect.
Where to look first. The base FormRequest (methods rules, authorize, messages, attributes, validated, all, input) is your contract. Then read the argument resolver (to understand when it activates) and the rule parser (to see which Laravel rules are actually translated into Symfony constraints). The exception/error renderer is the third stop — that is what you customize for API error shape.
One request class per action, not per resource. StoreUserRequest and UpdateUserRequest diverge quickly (required vs. sometimes, password vs. nullable|confirmed). Splitting them keeps rules declarative and avoids conditional if ($this->isMethod('PATCH')) branches in rules(). When PATCH fields overlap heavily, compose shared rule arrays via a small trait or static method rather than inheriting one request from another.
Treat the form request as a validation boundary, not a DTO. Use validated() to obtain the whitelisted subset, then immediately map to a command/DTO/value object and pass that into your handler. Do not leak the FormRequest into services or domain code — it couples everything to HTTP and makes the validated data shape implicit.
Put authorization in authorize() only when it is cheap and request-local. "Is this user allowed to create a post?" belongs there and keeps controllers thin. Anything needing a loaded entity (e.g., "can this user edit this post?") is better done with a Symfony voter after you have resolved the entity, or by fetching and denyAccessUnlessGranted inside the controller. Form-request authorize() runs before your action, so you cannot rely on entities you load in the action.
Use messages() and attributes() for human-facing surfaces. messages() keyed by field.rule and attributes() keyed by field name give you localized, branded errors without touching the parser. For APIs, skip custom messages and instead normalize the error payload once via an exception listener so every endpoint returns the same shape (e.g., RFC 7807 problems with a errors map).
Nested and array input. Dot-notation rules (items.*.sku => required|string) are the pattern to reach for; verify in the parser that wildcards are expanded and that array-level rules (items => required|array|min:1) are applied to the parent. For deeply nested or polymorphic payloads, validate the outer shell with the form request and the inner variants with a dedicated Symfony validator call or a Collection constraint — do not try to express everything in one rule string.
Testing pattern. Functional tests with WebTestCase asserting status (422/403/201) and the error body catch resolver and rendering regressions. Separately, unit-test each request by instantiating it with a constructed Symfony Request and asserting rules() and authorize() — cheap, fast, and documents the contract. Keep one "valid sample payload" fixture per request reused across tests so schema drift is visible.
Incremental migration. For an existing controller doing manual $validator->validate(...), move rules into a request class per action and switch the type-hint; leave the controller body untouched initially, then replace manual extraction with validated() in a second pass. This keeps diffs reviewable and isolates behavior changes to validation/error rendering.
unique, exists, regex, custom Rule objects — often have no mapping or a subtly different one. Read the parser source and add a test per rule you rely on; a silently ignored rule is worse than a hard failure. When you need more, use a Symfony constraint or a callback rather than contorting a string.#[MapRequestPayload] / RequestPayloadValueResolver (6.3+) can collide conceptually — pick one approach per argument.validated() vs all() / input(). all() may include CSRF tokens, _method, and extra client-sent fields. Always whitelist via validated() before persisting, and be aware validated() returns only keys with rules — fields you forgot to rule silently disappear.Accept: application/json in tests.false from authorize() produces (403 vs. a validation-style 422). If it differs from your app's policy expectations, convert policy checks to voters and keep authorize() returning true._method=PATCH should still present isMethod('PATCH') semantics. If your rules() branch on method, verify through the real Symfony request rather than assuming.rules(). It runs on every request to the endpoint; treat it as static configuration. If a rule must be data-dependent (e.g., allowed transitions), compute it once or move the check into a custom constraint.confirmed-style rules. Check explicitly how uploaded files are validated (UploadedFile constraints) and whether confirmed expects password_confirmation in the body. These are the two rules most likely to behave differently from Laravel expectations.#[MapRequestPayload] + validator or a hand-rolled validator-in-controller approach; the form-request ergonomics are nice, but you should know the fallback if the package lags a Symfony major.rules() (if supported) or by extending the parser. Keep local extensions in App\ namespace and covered by tests so a package upgrade surfaces breakage early.How can I help you explore Laravel packages today?