Cleaning Up Laravel Controllers

ashallendesign

Ash Allen

Posted on June 25, 2021

Cleaning Up Laravel Controllers

Introduction

Controllers play a huge role in any MVC (model view controller) based project. They're effectively the "glue" that takes a user's request, performs some type of logic, and then returns a response. If you've ever worked on any fairly large projects, you'll notice that you'll have a lot of controllers and that they can start to get messy quite quickly without you realising. In this article we're going to look at how we can clean up a bloated controller in Laravel.

The Problem with Bloated Controllers

Bloated controllers can cause several problems for developers. They can:

  1. Make it hard to track down a particular piece of code or functionality. If you're looking to work on a particular piece of code that's in a bloated controller, you might need to spend a while tracking down which controller the method is actually in. When using clean controllers that are logically separated this is much easier.
  2. Make it difficult to spot the exact location of a bug. As we'll see in our code examples later on, if we're handling authorization, validation, business logic and response building all in one place, it can be difficult to pinpoint the exact location of a bug.
  3. Make it harder to write tests for more complex requests. It can sometimes be difficult to write tests for complex controller methods that have a lot of lines and are doing many different things. Cleaning up the code makes testing much easier. Check out this article if you're interested in finding out how to make your Laravel app more testable.

The Bloated Controller

For this article, we're going to use an example UserController:

class UserController extends Controller
{
    public function store(Request $request): RedirectResponse
    {
        $this->authorize('create', User::class);

        $request->validate([
            'name'     => 'string|required|max:50',
            'email'    => 'email|required|unique:users',
            'password' => 'string|required|confirmed',
        ]);

        $user = User::create([
            'name'     => $request->name,
            'email'    => $request->email,
            'password' => $request->password,
        ]);

        $user->generateAvatar();
        $this->dispatch(RegisterUserToNewsletter::class);

        return redirect(route('users.index'));
    }

    public function unsubscribe(User $user): RedirectResponse
    {
        $user->unsubscribeFromNewsletter();

        return redirect(route('users.index'));
    }
}
Enter fullscreen mode Exit fullscreen mode

To keep things nice and simple to read, I haven't included the index(), create(), edit(), update() and delete() methods in the controller. But we'll make the assumption that they are there and that we're also using the below techniques to clean up those methods too. For the majority of the article, we'll be focusing on optimizing the store() method.

1. Lift Validation and Authorization into Form Requests

One of the first things that we can do with the controller is to lift any validation and authorization out of the controller and into a form request class. So, let's take a look at how we could do this for the controller's store() method.

We'll use the following Artisan command to create a new form request:

php artisan make:request StoreUserRequest
Enter fullscreen mode Exit fullscreen mode

The above command will have created a new app/Http/Requests/StoreUserRequest.php class that looks like this:

class StoreUserRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        return false;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules()
    {
        return [
            //
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

We can use the authorize() method to determine if the user should be allowed to carry out the request. The method should return true if they can and false if they cannot. We can also use the rules() method to specify any validation rules that should be run on the request body. Both of these methods will run automatically before we manage to run any code inside our controller methods without us needing to manually call either of them.

So, let's move our authorization from the top of our controller's store() method and into the authorize() method. After we've done this, we can move the validation rules from the controller and into the rules() method. We should now have a form request that looks like this:

class StoreUserRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize(): bool
    {
        return Gate::allows('create', User::class);
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules(): array
    {
        return [
            'name'     => 'string|required|max:50',
            'email'    => 'email|required|unique:users',
            'password' => 'string|required|confirmed',
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

Our controller should now also look like this:

class UserController extends Controller
{
    public function store(StoreUserRequest $request): RedirectResponse
    {
        $user = User::create([
            'name'     => $request->name,
            'email'    => $request->email,
            'password' => $request->password,
        ]);

        $user->generateAvatar();
        $this->dispatch(RegisterUserToNewsletter::class);

        return redirect(route('users.index'));
    }

    public function unsubscribe(User $user): RedirectResponse
    {
        $user->unsubscribeFromNewsletter();

        return redirect(route('users.index'));
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice how in our controller, we've changed the first argument of the store() method from a \Illuminate\Http\Request to our new \App\Http\Requests\StoreUserRequest. We've also managed to reduce some of the bloat for the controller method by extracting it out into the request class.

Note: For this to work automatically, you'll need to make sure that your controller is using the \Illuminate\Foundation\Auth\Access\AuthorizesRequests and \Illuminate\Foundation\Validation\ValidatesRequests traits. These come automatically included in the controller that Laravel provides you in a fresh install. So, if you're extending that controller, you're all set to go. If not, make sure to include these traits into your controller.

2. Move Common Logic into Actions or Services

Another step that we could take to clean up the store() method could be to move out our "business logic" into a separate action or service class.

In this particular use case, we can see that the main functionality of the store() method is to create a user, generate their avatar and then dispatch a queued job that registers the user to the newsletter. In my personal opinion, an action would be more suitable for this example rather than a service. I prefer to use actions for small tasks that do only particular thing. Whereas for larger amounts of code that could potentially be hundreds of lines long and do multiple things, it would be more suited to a service.

So, let's create our action by creating a new Actions folder inside our app folder and then creating a new class inside this folder called StoreUserAction.php. We can then move the code into the action like this:

class StoreUserAction
{
    public function execute(Request $request): void
    {
        $user = User::create([
            'name'     => $request->name,
            'email'    => $request->email,
            'password' => $request->password,
        ]);

        $user->generateAvatar();
        $this->dispatch(RegisterUserToNewsletter::class);
    }
}
Enter fullscreen mode Exit fullscreen mode

Now we can update our controller to use the action:

class UserController extends Controller
{
    public function store(StoreUserRequest $request, StoreUserAction $storeUserAction): RedirectResponse
    {
        $storeUserAction->execute($request);

        return redirect(route('users.index'));
    }

    public function unsubscribe(User $user): RedirectResponse
    {
        $user->unsubscribeFromNewsletter();

        return redirect(route('users.index'));
    }
}
Enter fullscreen mode Exit fullscreen mode

As you can see, we've now been able to lift the business logic out of the controller method and into the action. This is useful because, as I mentioned before, controllers are essentially the "glue" for our requests and responses. So, we've managed to reduce the cognitive load for understanding what a method does by keeping the code logically separated. For example, if we want to check the authorization or validation, we know to check the form request. If we want to check what's being done with the request data, we can check the action.

Another huge benefit to abstracting the code out into these separate classes is that it can make testing a lot easier and faster. I've briefly talked about this in my past article about how to make your Laravel app more testable; so I'd definitely recommend giving that a read if you haven't already.

Using DTOs with Actions

Another great benefit of extracting your business logic into services and classes is that you can now use that logic in different places without needing to duplicate your code. For example, let's assume that we have a UserController that handles traditional web requests and an Api\UserController that handles API requests. For the sake of argument, we can make the assumption that the general structure of the store() methods for those controllers will be the same. But, what would we do if our API request we doesn't use an email field, but instead uses an email_address field? We wouldn't be able to pass our request object to the StoreUserAction class because it would be expecting a request object that has an email field.

To solve this issue, we can use DTOs (data transfer objects). These are a really useful way of decoupling data and passing it around your system's code without it being tightly coupled to anything (in this case, the request). To add DTOs to our project, we'll use Spatie's spatie/data-transfer-object package and install it using the following Artisan command:

composer require spatie/data-transfer-object
Enter fullscreen mode Exit fullscreen mode

Now that we have the package installed, let's create a new DataTransferObjects folder inside our App folder and create a new StoreUserDTO.php class. We'll then need to make sure that our DTO extends Spatie\DataTransferObject\DataTransferObject. We can then define our three fields like so:

class StoreUserDTO extends DataTransferObject
{
    public string $name;

    public string $email;

    public string $password;
}
Enter fullscreen mode Exit fullscreen mode

Now that we've done this, we can add a new method to our StoreUserRequest from before to create and return a StoreUserDTO class like so:

class StoreUserRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize(): bool
    {
        return Gate::allows('create', User::class);
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules(): array
    {
        return [
            'name'     => 'string|required|max:50',
            'email'    => 'email|required|unique:users',
            'password' => 'string|required|confirmed',
        ];
    }

    /**
     * Build and return a DTO.
     *
     * @return StoreUserDTO
     */
    public function toDTO(): StoreUserDTO
    {
        return new StoreUserDTO(
            name: $this->name,
            email: $this->email,
            password: $this->password,
        );
    } 
}
Enter fullscreen mode Exit fullscreen mode

We can now update our controller to pass the DTO to the action class:

class UserController extends Controller
{
    public function store(StoreUserRequest $request, StoreUserAction $storeUserAction): RedirectResponse
    {
        $storeUserAction->execute($request->toDTO());

        return redirect(route('users.index'));
    }

    public function unsubscribe(User $user): RedirectResponse
    {
        $user->unsubscribeFromNewsletter();

        return redirect(route('users.index'));
    }
}
Enter fullscreen mode Exit fullscreen mode

Finally, we'll need to update our action's method to accept a DTO as an argument rather than the a request object:

class StoreUserAction
{
    public function execute(StoreUserDTO $storeUserDTO): void
    {
        $user = User::create([
            'name'     => $storeUserDTO->name,
            'email'    => $storeUserDTO->email,
            'password' => $storeUserDTO->password,
        ]);

        $user->generateAvatar();
        $this->dispatch(RegisterUserToNewsletter::class);
    }
}
Enter fullscreen mode Exit fullscreen mode

As a result of doing all of this, we have now completely decoupled our action from the request object. This means that we can reuse this action in multiple places across the system without being tied to a specific request structure. We would now also be able to use this approach in a CLI environment or queued job that isn't tied to a web request. As an example, if our application had the functionality to import users from a CSV file, we would be able to build the DTOs from the CSV data and pass it in to the action.

To go back to our original problem of having an API request that used email_address rather than email, we would now be able to solve it by simply building the DTO and assigning the DTO's email field the request's email_address field. Let's imagine that the API request had it's own separate form request class. It could look like this as an example:

class StoreUserAPIRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize(): bool
    {
        return Gate::allows('create', User::class);
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules(): array
    {
        return [
            'name'          => 'string|required|max:50',
            'email_address' => 'email|required|unique:users',
            'password'      => 'string|required|confirmed',
        ];
    }

    /**
     * Build and return a DTO.
     *
     * @return StoreUserDTO
     */
    public function toDTO(): StoreUserDTO
    {
        return new StoreUserDTO(
            name: $this->name,
            email: $this->email_address,
            password: $this->password,
        );
    } 
}
Enter fullscreen mode Exit fullscreen mode

3. Use Resource or Single-use Controllers

A great way of keeping controllers clean is to ensure that they are either "resource controllers" or "single-use controllers". Before we go any further and try to update our example controller, let's take a look at what both of these terms mean.

A resource controller is a controller that provides functionality based around a particular resource. So, in our case, our resource is the User model and we want to be able to perform all CRUD (create, update, update, delete) operations on this model. A resource controller typically contains index(), create(), store(), show(), edit(), update() and destroy() methods. It doesn't necessarily have to include all of these methods, but it wouldn't have any methods that weren't in this list. By using these types of controllers, we can make our routing RESTful. For more information about REST and RESTful routing, check out this article here.

A single-use controller is a controller that only has one public __invoke() method. These are really useful if you have a controller that doesn't really fit into one of the RESTful methods that we have in our resource controllers.

Based off the above information, we can see that the our UserController could probably be improved by moving the unsubscribe method to its own single-use controller. By doing this, we'd be able to make the UserController a resource controller that only includes resource methods.

So let's create a new controller using the following Artisan command:

php artisan make:controller UnsubscribeUserController -i
Enter fullscreen mode Exit fullscreen mode

Notice how we passed -i to the command so that the new controller will be an invokable, single-use controller. We should now have a controller that looks like this:

class UnsubscribeUserController extends Controller
{
    public function __invoke(Request $request)
    {
        //
    }
}
Enter fullscreen mode Exit fullscreen mode

We can now move our method's code over and delete the unsubscribe method from our old controller:

class UnsubscribeUserController extends Controller
{
    public function __invoke(Request $request): RedirectResponse
    {
        $user->unsubscribeFromNewsletter();

        return redirect(route('users.index'));
    }
}
Enter fullscreen mode Exit fullscreen mode

Make sure that you remember to switch over your route in your routes/web.php file to call the use the UnsubscribeController controller rather than the UserController for this method.

Conclusion

Hopefully this article has given you an insight into the different types of things you can do to clean up your controllers in your Laravel projects. Please remember though that the techniques I've used here are only my personal opinion. I'm sure that there are other developers that would use a totally different approach to building their controllers. The most important part is being consistent and using an approach that fits in with your (and your teams) workflow.

I'd love to hear in the comments what types of techniques you use for writing clean controllers.

If you also found this article useful, feel free to sign up to my newsletter so that you can get notified whenever I release new posts similar to this one.

Keeping on building awesome stuff! 🚀

💖 💪 🙅 🚩
ashallendesign
Ash Allen

Posted on June 25, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related