If FormRequests and invokable Controllers had a baby
From the feedback I usually receive from Laravel Actions, I get the impression that the biggest value people get out of this package is the ability to create a controller that contains its own authorisation and validation logic.
At the end of the day, everybody like invokable Controllers and it's a pain to create a new FormRequest
for almost every single one of them. Not to mention, you then have to maintain two files — generally in different folders — that handles the same endpoint. Ergh.
Well, it turns out, if all you need is an invokable Controller that can handle its own authorisation and validation, then you don't need Laravel Actions for that.
In fact all you need is to use a FormRequest
as a controller. Kind of...
__invoke
method
A FormRequest with an Since the FormRequest
class contains all the logic we need to authorise and validate a request, let's use that one as our base class.
Now if we just stick an __invoke
method on it, then Laravel's ControllerDispatcher
will have no problem running it.
And since FormRequests
are automatically validated when they get resolved from the container, this actually works and your data will be validated.
class MyController extends FormRequest
{
public function authorize()
{
return true;
}
public function rules()
{
return [];
}
public function __invoke()
{
// ...
}
}
Well not really... It will only work** the first time** you run that endpoint!
The request that never moves on
The problem here is that the controller object that has been resolved from the container is cached on the Route
object itself. Which means, the next time someone will hit the same endpoint, the exact same controller object will be used.
And that would be fine if we did not decide to use a request as a controller! 😅
Because now, no matter how many time you hit that endpoint, the request will always be the same as the very first one the endpoint received. Not very handy if you ask me.
But we're not going to let that stop us. Let's fix this!
callAction
, the real hero
Fortunately for us, the framework makes another call on the controller before hitting to the __invoke
method. And that's the callAction
method.
If present on the controller, it will be used to determine which method should be run on that controller.
It defaults to this, which is why we tend to forget about it.
public function callAction($method, $parameters)
{
return $this->{$method}(...array_values($parameters));
}
But in our case, it's super helpful because we can tell our request/controller mixup to refresh itself to the latest request received.
To understand how we can do this, let's see how the framework resolves FormRequests
and what we can learn from it.
Learning from the framework
When diving into the FormRequestServiceProvider
, we can learn two things.
class FormRequestServiceProvider extends ServiceProvider
{
public function register()
{
//
}
public function boot()
{
// 1.
$this->app->afterResolving(ValidatesWhenResolved::class, function ($resolved) {
$resolved->validateResolved();
});
// 2.
$this->app->resolving(FormRequest::class, function ($request, $app) {
$request = FormRequest::createFrom($app['request'], $request);
$request->setContainer($app)->setRedirector($app->make(Redirector::class));
});
}
}
- Since the
FormRequest
class implements theValidatesWhenResolved
interface, we can see that thevalidateResolved
method is automatically called after it's resolved. This is useful to know because we'll need to disable that behaviour. Instead of validating the request when the controller is created — only once per route — we'll need to use that same method in thecallAction
so it is triggered every time we receive a new request. - It ensures the
FormRequest
is up-to-date with the latestRequest
by copying its attributes. As you can see, this is done with the static methodcreateFrom($from, $to)
in the resolving callback.
Refreshing the request
Okay so let's use what we learned to refresh the request within the callAction
method.
- First we call
static::createFrom(request(), $this);
to refresh our request/controller with the latest data. - But that's not enough. The
FormRequest
also has a$validator
property that caches the latest resolved validator. Thus, we need to invalidate that before receiving a new request. A simple$this->validator = null
will suffice. - Finally, we need tell our request to start the validation process again. We've learned from the previous section that we can achieve this with
$this->validateResolved()
. - Since we also don't want validation to run twice for the first request we receive on any endpoint, we need to disable the auto-validation thingy. The easiest way to do this is to override the
validateResolved
method so that it is empty. Which mean, we now need to useparent::validateResolved()
on thecallAction
method instead.
Therefore, we end up with the following request/controller base class that you can use to fully enjoy the benefits of FormRequests
within your controllers.
class RequestController extends FormRequest
{
public function callAction($method, $parameters)
{
$this->refreshRequestAndValidate();
return $this->{$method}(...array_values($parameters));
}
public function validateResolved()
{
//
}
protected function refreshRequestAndValidate()
{
$this->validator = null;
static::createFrom(request(), $this);
parent::validateResolved();
}
}
There's a package for you
This is probably the smallest Laravel package I've ever written but at least we now have a polished RequestController
class we can all contribute towards.
I also added the ability to register middleware directly within the controller via a new optional middleware()
method.
class MyController extends RequestController
{
public function middleware()
{
return [
Authenticated::class,
Subscribed::class,
];
}
// ...
}
I didn't want to detail the process here since it's already turning into a longer article that I had anticipated but feel free to dig into the RequestController
class to see how it's done.
Anyway, I hope it'll be useful to some of you or that you've learned something useful in this article.
Cheers. 🌷