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:
- 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.
- 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.
- 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
:
1class UserController extends Controller 2{ 3 public function store(Request $request): RedirectResponse 4 { 5 $this->authorize('create', User::class); 6 7 $request->validate([ 8 'name' => 'string|required|max:50', 9 'email' => 'email|required|unique:users',10 'password' => 'string|required|confirmed',11 ]);12 13 $user = User::create([14 'name' => $request->name,15 'email' => $request->email,16 'password' => $request->password,17 ]);18 19 $user->generateAvatar();20 $this->dispatch(RegisterUserToNewsletter::class);21 22 return redirect(route('users.index'));23 }24 25 public function unsubscribe(User $user): RedirectResponse26 {27 $user->unsubscribeFromNewsletter();28 29 return redirect(route('users.index'));30 }31}
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:
1php artisan make:request StoreUserRequest
The above command will have created a new app/Http/Requests/StoreUserRequest.php
class that looks like this:
1class StoreUserRequest extends FormRequest 2{ 3 /** 4 * Determine if the user is authorized to make this request. 5 * 6 * @return bool 7 */ 8 public function authorize() 9 {10 return false;11 }12 13 /**14 * Get the validation rules that apply to the request.15 *16 * @return array17 */18 public function rules()19 {20 return [21 //22 ];23 }24}
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:
1class StoreUserRequest extends FormRequest 2{ 3 /** 4 * Determine if the user is authorized to make this request. 5 * 6 * @return bool 7 */ 8 public function authorize(): bool 9 {10 return Gate::allows('create', User::class);11 }12 13 /**14 * Get the validation rules that apply to the request.15 *16 * @return array17 */18 public function rules(): array19 {20 return [21 'name' => 'string|required|max:50',22 'email' => 'email|required|unique:users',23 'password' => 'string|required|confirmed',24 ];25 }26}
Our controller should now also look like this:
1class UserController extends Controller 2{ 3 public function store(StoreUserRequest $request): RedirectResponse 4 { 5 $user = User::create([ 6 'name' => $request->name, 7 'email' => $request->email, 8 'password' => $request->password, 9 ]);10 11 $user->generateAvatar();12 $this->dispatch(RegisterUserToNewsletter::class);13 14 return redirect(route('users.index'));15 }16 17 public function unsubscribe(User $user): RedirectResponse18 {19 $user->unsubscribeFromNewsletter();20 21 return redirect(route('users.index'));22 }23}
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:
1use Illuminate\Foundation\Bus\DispatchesJobs; 2 3class StoreUserAction 4{ 5 use DispatchesJobs; 6 7 public function execute(Request $request): void 8 { 9 $user = User::create([10 'name' => $request->name,11 'email' => $request->email,12 'password' => $request->password,13 ]);14 15 $user->generateAvatar();16 $this->dispatch(RegisterUserToNewsletter::class);17 }18}
Now we can update our controller to use the action:
1class UserController extends Controller 2{ 3 public function store(StoreUserRequest $request, StoreUserAction $storeUserAction): RedirectResponse 4 { 5 $storeUserAction->execute($request); 6 7 return redirect(route('users.index')); 8 } 9 10 public function unsubscribe(User $user): RedirectResponse11 {12 $user->unsubscribeFromNewsletter();13 14 return redirect(route('users.index'));15 }16}
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:
1composer require spatie/data-transfer-object
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:
1class StoreUserDTO extends DataTransferObject2{3 public string $name;4 5 public string $email;6 7 public string $password;8}
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:
1class StoreUserRequest extends FormRequest 2{ 3 /** 4 * Determine if the user is authorized to make this request. 5 * 6 * @return bool 7 */ 8 public function authorize(): bool 9 {10 return Gate::allows('create', User::class);11 }12 13 /**14 * Get the validation rules that apply to the request.15 *16 * @return array17 */18 public function rules(): array19 {20 return [21 'name' => 'string|required|max:50',22 'email' => 'email|required|unique:users',23 'password' => 'string|required|confirmed',24 ];25 }26 27 /**28 * Build and return a DTO.29 *30 * @return StoreUserDTO31 */32 public function toDTO(): StoreUserDTO33 {34 return new StoreUserDTO(35 name: $this->name,36 email: $this->email,37 password: $this->password,38 );39 }40}
We can now update our controller to pass the DTO to the action class:
1class UserController extends Controller 2{ 3 public function store(StoreUserRequest $request, StoreUserAction $storeUserAction): RedirectResponse 4 { 5 $storeUserAction->execute($request->toDTO()); 6 7 return redirect(route('users.index')); 8 } 9 10 public function unsubscribe(User $user): RedirectResponse11 {12 $user->unsubscribeFromNewsletter();13 14 return redirect(route('users.index'));15 }16}
Finally, we'll need to update our action's method to accept a DTO as an argument rather than the a request object:
1use Illuminate\Foundation\Bus\DispatchesJobs; 2 3class StoreUserAction 4{ 5 use DispatchesJobs; 6 7 public function execute(StoreUserDTO $storeUserDTO): void 8 { 9 $user = User::create([10 'name' => $storeUserDTO->name,11 'email' => $storeUserDTO->email,12 'password' => $storeUserDTO->password,13 ]);14 15 $user->generateAvatar();16 $this->dispatch(RegisterUserToNewsletter::class);17 }18}
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:
1class StoreUserAPIRequest extends FormRequest 2{ 3 /** 4 * Determine if the user is authorized to make this request. 5 * 6 * @return bool 7 */ 8 public function authorize(): bool 9 {10 return Gate::allows('create', User::class);11 }12 13 /**14 * Get the validation rules that apply to the request.15 *16 * @return array17 */18 public function rules(): array19 {20 return [21 'name' => 'string|required|max:50',22 'email_address' => 'email|required|unique:users',23 'password' => 'string|required|confirmed',24 ];25 }26 27 /**28 * Build and return a DTO.29 *30 * @return StoreUserDTO31 */32 public function toDTO(): StoreUserDTO33 {34 return new StoreUserDTO(35 name: $this->name,36 email: $this->email_address,37 password: $this->password,38 );39 }40}
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:
1php artisan make:controller UnsubscribeUserController -i
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:
1class UnsubscribeUserController extends Controller2{3 public function __invoke(User $user)4 {5 //6 }7}
We can now move our method's code over and delete the unsubscribe
method from our old controller:
1class UnsubscribeUserController extends Controller2{3 public function __invoke(User $user): RedirectResponse4 {5 $user->unsubscribeFromNewsletter();6 7 return redirect(route('users.index'));8 }9}
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 below so that you can get notified whenever I release new posts similar to this one.
Keeping on building awesome stuff! 🚀