lorisleiva/request-controller
A Laravel package that lets Form Requests act as invokable controllers, combining validation, authorization, and request handling in a single class for cleaner single-action endpoints.
lorisleiva/request-controller merges two classes you normally write for every write endpoint — a FormRequest for authorization/validation and an invokable controller for the action — into a single class. The class extends the package's request-controller base, uses Laravel's normal FormRequest validation lifecycle, and exposes an action method (conventionally handle()) that is invoked when the route dispatches.
Install it:
composer require lorisleiva/request-controller
Look first at the package's base class in vendor/lorisleiva/request-controller/src and at the README example. The base class is small; most behavior you rely on is inherited from Laravel's FormRequest (authorize(), rules(), prepareForValidation(), validated(), automatic redirect/422 responses).
A first use case — a POST /posts endpoint:
namespace App\Http\Controllers\Posts;
use App\Models\Post;
use Lorisleiva\RequestController\RequestController;
class StorePost extends RequestController
{
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'body' => ['required', 'string'],
];
}
public function handle()
{
$post = Post::create($this->validated());
return redirect()->route('posts.show', $post);
}
}
Route::post('/posts', StorePost::class)->name('posts.store');
That is the whole day-one workflow: define rules, put the action body in handle(), register the class as the route action. If you need authorization, add authorize(); if you need input normalization, add prepareForValidation().
One class per endpoint. The package fits command-style endpoints best: store, update, destroy, login, logout, webhooks, single-purpose form submissions. It is a poor fit for large resource controllers with many methods, because each method becomes its own class.
Co-locate authorization, normalization, validation, and the action.
class UpdatePost extends RequestController
{
public function authorize(): bool
{
return $this->user()->can('update', $this->route('post'));
}
public function prepareForValidation(): void
{
$this->merge(['slug' => Str::slug($this->input('title'))]);
}
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'slug' => ['required', 'string', Rule::unique('posts')->ignore($this->route('post'))],
];
}
public function handle()
{
$post = $this->route('post');
$post->update($this->validated());
return back()->with('status', 'Post updated.');
}
}
Use route model binding through the request. Because the class is the request, route parameters are available with $this->route('post'). Prefer that over trying to type-hint route-bound models into the action method; behavior there depends on how the package invokes the action and is less portable across versions.
Share behavior with an intermediate base class. If you have many request controllers, create App\Http\Controllers\RequestController extending the package class and put cross-cutting helpers there (e.g. a respond() helper, common authorization checks). This is also where you would add a failedValidation() override if you want a consistent error envelope.
Reuse validation rules outside the endpoint. Since the class is a FormRequest, its rules() are only usable through HTTP dispatch. If the same rules are needed by a console command or another endpoint, extract them to a dedicated rules class or trait and call it from rules().
Test through HTTP feature tests, not by instantiating the class. The validation lifecycle fires when the container resolves the request during route dispatch. Test the endpoint as a client:
public function test_title_is_required(): void
{
$this->post(route('posts.store'), ['body' => 'Hello'])
->assertSessionHasErrors('title');
}
Keep domain logic out of the request class. The request controller is a delivery mechanism. Delegate to an action/service/job for anything reusable, transactional, or complex; let handle() validate, authorize, call the domain code, and return a response.
Apply middleware at the route level. Use route groups and ->middleware(...) rather than relying on a controller middleware() method; the class does not extend Laravel's base Controller, so controller middleware conventions do not apply.
Naming convention. These classes are controllers, not plain requests, so put them under App\Http\Controllers (possibly in an Posts subnamespace) and name them after the action (StorePost, UpdatePost). Avoid the ...Request suffix or you will confuse readers and IDE navigation.
composer.json constraints against your Laravel version and run a smoke test on your target version before committing. If it is incompatible, the pattern is easy to reproduce with a small base class of your own.FormRequest/Symfony Request subclass, the constructor signature is owned by the framework. Resolve services inside handle() via app() or method injection, not via __construct.handle() directly, authorize()/rules() will not run. Feature-test through the router.authorize() produces a 403 before validation errors are considered. This is the normal FormRequest order, but it surprises people who expect validation-first feedback.validated(), not all(). all() includes unvalidated keys, which is a mass-assignment footgun. validated() returns only keys covered by rules; on newer Laravel versions safe() is also available, but validated() is the compatible choice.Controller subclass. Put auth/throttle/can middleware on the route or route group.php artisan route:cache and php artisan route:list as part of your checks so a broken action reference is caught early.route:list. If you get a 419, it is CSRF/middleware, not the package. If you get a 500 on resolution, check the package's Laravel version constraints.authorize(), rules(), messages(), attributes(), prepareForValidation(), passedValidation(), and failedValidation() all behave as they do on a normal FormRequest. For custom JSON error shapes, override failedValidation() in an intermediate base class rather than in every endpoint.FormRequest already does with one extra file.How can I help you explore Laravel packages today?