maskow/livewire-combined-request
Laravel FormRequest base class that works in HTTP controllers, APIs, and Livewire 3/4 components. Write validation rules, authorization, required parameters, and file upload handling once, then reuse them everywhere. Zero configuration, Laravel 10/11/12 ready.
Install and confirm it loads. composer require maskow/livewire-combined-request — no service provider, no config file, no publish step. Confirm with composer show maskow/livewire-combined-request and then open the single base class in vendor/maskow/livewire-combined-request/src/CombinedFormRequest.php. Because the public surface is one class and a handful of static configuration methods, reading it end-to-end takes a few minutes and is the fastest way to understand exactly what happens when you call validateLivewire().
Where to look first, in order:
parameter(), hasParameter(), parameters(), validateLivewire(), notifyAuthorizationUsing(), convertCamelCaseToSnakeCase(), and the snake_case return toggle.UpdateTeamRequest — it exercises requiredParameters, authorize(), rules(), messages() in one file.save()). The value proposition only clicks when you see the same class instantiated two different ways.First use case — pick a resource you currently validate twice. The ideal pilot is an edit form that already exists as both a Blade/HTTP endpoint and a Livewire component (e.g. a profile or team settings page). Do this:
App\Http\Requests\UpdateTeamRequest extends CombinedFormRequest.protected array $requiredParameters = ['team', 'workspace'];.authorize() using $this->parameter('team').$this->validate([...]) with UpdateTeamRequest::validateLivewire($this, ['team' => $this->team, 'workspace' => $this->workspace]).If step 5 works without touching the rules, you have the pattern and can copy it across the app. Don't roll it out fleet-wide before that pilot — see the maturity note in Gotchas.
One request class per operation, not per transport. The whole point is that UpdateTeamRequest is a domain object ("what does it take to update a team") rather than an HTTP object. Name them after intent (StoreProjectRequest, InviteMemberRequest) and put them in App\Http\Requests where Laravel devs expect them. Resist creating UpdateTeamApiRequest and UpdateTeamLivewireRequest — that recreates the duplication you're removing.
Treat $requiredParameters as a context contract. They are checked when the request is constructed, so a Livewire call that forgets a dependency fails loudly instead of silently authorizing against null. Declare every model/value authorize() or rules() will touch:
protected array $requiredParameters = ['team', 'workspace', 'user_id'];
Note the README's template_id example — "required" means the key must be supplied, not the value must be non-null. That is the right semantic for optional models: pass $this->selectedTemplate?->id and let the rule (nullable|exists:templates,id) decide.
Pass models, not IDs, into Livewire. In HTTP the container resolves {team} to a Team instance; mirror that in Livewire by passing $this->team rather than $this->team_id. Then authorize() and rules() are literally identical code in both contexts, which is what makes the abstraction honest. Reserve primitives (auth()->id()) for things with no route binding equivalent.
Build a project-wide base request. Because the class is designed to be extended, create App\Http\Requests\BaseRequest extends CombinedFormRequest and put shared behaviour there: a default authorize() that runs a global gate, a prepareForValidation() that trims strings, a attributes() map reusing your translation file. Then every concrete request stays small.
Lean on the standard hooks — they're the real integration point.
protected function prepareForValidation(): void
{
$this->merge(['slug' => Str::slug($this->name)]);
}
public function withValidator($validator): void
{
$validator->after(fn ($v) => $this->exceedsLimit()
&& $v->errors()->add('project', 'Project limit reached.'));
}
protected function passedValidation(): void
{
activity()->log('team.update.validated');
}
Because these fire identically in both contexts, cross-cutting concerns (normalisation, quota checks, audit logging) live in one place. That is where most of the day-to-day value accrues — rules() deduplication is the obvious win, withValidator deduplication is the one that saves you from subtle drift.
Decide the casing policy once, per project. If your components already use snake_case public properties, leave convertCamelCaseToSnakeCase() off (the default) and do nothing. If your Livewire properties are camelCase — the Livewire convention — enable it in AppServiceProvider::boot():
CombinedFormRequest::convertCamelCaseToSnakeCase(true);
// and, when writing straight to the DB:
CombinedFormRequest::returnValidatedDataAsSnakeCase(true); // if the package version exposes it
Pair this with the snake_case validated-data option when you do $model->update($validated), otherwise you get camelCase keys (firstName) hitting snake_case columns (first_name) and silently-null attributes. Mixing an enabled conversion with camelCase-only components is fine; mixing it with hand-written camelCase rules is not — errors get remapped and your rule keys won't match.
Wire the authorization notifier early. In Livewire, a failed authorize() has no redirect/403 response to fall back on. Register a global handler in AppServiceProvider::boot() before your first component ships:
CombinedFormRequest::notifyAuthorizationUsing(function ($component, string $message): void {
$component->dispatch('notify', ['type' => 'error', 'message' => $message]);
});
Choose one channel (browser event, toast, or session()->flash) and use it everywhere. $component is live, so $component->dispatch(...) reaches the actual component rather than the browser globally.
Catch InvalidArgumentException at the component boundary, not in the request. A missing parameter is a programming error, so let it throw — but in component actions that users trigger, wrap the call and flash something readable, exactly as the README's save() does. Better still: add a feature test per component action that asserts no exception is thrown, which catches a dropped parameter at CI time instead of in production.
Test the request once, and smoke-test both paths. Unit-test rules()/authorize() through whichever context is cheapest. Then keep one HTTP feature test (assertStatus(422)) and one Livewire::test(EditTeamForm::class)->call('save')->assertHasErrors('name') per request to prove the wiring hasn't drifted. The assertHasErrors key names are where the camelCase setting bites, so asserting them explicitly is worth the line.
File uploads work through the same path. Livewire's TemporaryUploadedFile satisfies the usual file/mimes/max rules, so UploadDocumentRequest can be shared between a multipart controller endpoint and a wire:model upload component with no special-casing — provided you pass the uploaded property through or let the component's public properties be read directly.
Maturity is the headline risk. One star, zero dependents, single maintainer, Apache-2.0, and a release date that reads as future-dated (2026-05-16) — treat this as pre-adoption code. Before putting it in a production path: read the whole source file, pin the exact version in composer.json, and note that the fallback is trivial (copy the class into App\Support and own it), since it's a thin wrapper over FormRequest plus a Livewire facade. Budget that "vendor and forget" exit ramp up front; it costs an hour and removes the bus-factor question entirely.
$requiredParameters is enforced at construction, so ordering and typos surface as InvalidArgumentException. The exception message names the class and the missing keys — read it literally. The usual causes: passing 'team' in the controller route but {teamModel} in the route definition; or in Livewire, renaming a property and forgetting the key in the validateLivewire() array. Silent null parameters are worse than the exception, so don't be tempted to "fix" it by making the array empty.
Route parameter names must match the parameter keys exactly. Route::put('/teams/{team}', ...) gives you parameter('team'); {team_id} gives you team_id and your 'team' requirement explodes. If you use explicit route-model binding ({team:slug}) the parameter is still keyed team, which is fine — but validate the shape in rules() since it's no longer necessarily numeric.
Authorization failures in Livewire are silent unless you register a notifier. Without notifyAuthorizationUsing(), the failure surfaces as an AuthorizationException in the Livewire response — users see nothing and the form "does nothing". This is the single most likely post-deploy bug report. Register the notifier in the same PR that introduces the first request, and assert it in a test.
CamelCase conversion is global and off by default. Two traps: (a) turning it on later changes error keys in every existing component at once, breaking assertHasErrors('first_name') tests and any $errors->has() lookups in Blade; (b) turning it on while some requests deliberately use camelCase rule keys produces double-mapping. Decide before you accumulate requests, and grep for @error( usages when you flip it.
validated() keys must match your persistence layer. With conversion enabled and camelCase components, the default return is camelCase — great for $this->fill(), wrong for update(). Either enable snake_case returns for requests that feed the database, or map explicitly ($validated['first_name'] = $validated['firstName']). Never let Model::update() filter out keys silently via $fillable; that bug is invisible until someone notices a field never saves.
Don't call validateLivewire() outside a component. It needs a real Livewire\Component instance for error bag attachment and the notifier callback. Calling it from a plain class, a job, or a console command will fail or attach errors nowhere useful. For non-Livewire, non-HTTP paths (imports, artisan commands), instantiate a Validator directly from the shared rules() — that's the legitimate third use of the class and worth documenting in your own repo.
ValidationException still propagates in Livewire. Livewire catches it and populates $errors, so try/catch only for InvalidArgumentException (missing params) and AuthorizationException if you're not using the notifier. Don't wrap everything in a blanket catch (\Throwable) — you'll swallow genuine rule errors and lose the field-level messages that make the shared request worthwhile.
prepareForValidation() mutates the request, not the component. If you merge() a computed value (a slug, a lowercased email), the Livewire component's public property is unchanged — you get the mutated data back in $validated, but the UI still shows the raw value. Use $this->fill($validated) after a successful save if you want the form to reflect normalisation.
Static configuration belongs in boot(), and only there. convertCamelCaseToSnakeCase() and notifyAuthorizationUsing() are static flags; calling them from a service provider's register(), a middleware, or a request constructor produces order-dependent behaviour that's miserable to debug. One boot() block, documented in the provider.
Extension points are the escape hatch. Override authorize() in BaseRequest to consult a shared policy gateway; override messages() to read from lang/; add a withValidator quota check that applies to every write request. If you hit a limitation, the class is small enough to subclass and override the specific method — and if the package is abandoned, that subclass becomes the migration path rather than a rewrite.
How can I help you explore Laravel packages today?