If FormRequests and invokable Controllers had a baby

4 years ago
4 min read

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...

A FormRequest with an __invoke method

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));
        });
    }
}
  1. Since the FormRequest class implements the ValidatesWhenResolved interface, we can see that the validateResolved 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 the callAction so it is triggered every time we receive a new request.
  2. It ensures the FormRequest is up-to-date with the latest Request by copying its attributes. As you can see, this is done with the static method createFrom($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 use parent::validateResolved() on the callAction 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.

RequestController on GitHub

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. 🌷

Discussions

Would you like to chime in?

You must be a member to start a new discussion.

Fortunately, it only takes two click to become one. See you on the other side! 🌸

Become a Member