A comprehensive guide on how to design future-proof controllers: Part 2
A series on how to use the Separation of concerns principle to build controllers that are simple to understand, refactor and maintain.
Introduction
This article is the second part of a series of articles where we talk about how separating code based on functionality can improve the quality of your codebase. If you have not read the first part, check it out here.
This article will focus on separating the request validation concern from the rest of the code in the Controller. I will be using Laravel in this tutorial. Still, you can apply the ideas we will be discussing here to any programming language that supports any form of code separation into packages, modules, etc., in such a way that you can use code written in file A
in file B
.
Prerequisites
- Reading the first part of this article here to understand some concepts and terminologies
- Knowledge of Object-Oriented Programming (OOP) is required
- Basic knowledge of Laravel
- If you want to test the code on your system, then you MUST have PHP installed on your system
Let's dive in π
You can find all the code from this article here.
What we currently have
Let's look at a simple controller function that adds a new user to the database during a registration process. I have added comments to illustrate how we can identify the three concerns involved in the register function.
/**
* create a new account
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function register(Request $request)
{
/*----Concern (Request validation)----*/
$validator = Validator::make($request->all(), [
'email' => 'required|email|unique:users',
'name' => 'required|string',
'password' => 'required|min:6'
]);
if ($validator->fails()) {
/*----Concern (Sending response to the client)----*/
return response()->json(
[
"status" => false,
"message" => $validator->errors()
],
422
);
}
/*----Concern (Handling business logic (In this case, hashing the user's password and interaction with database)----*/
$password = Hash::make($request->password);
$user = User::create([
'name' => $request->name,
'password' => $password,
'email' => $request->email
]);
/*----Concern (Sending response to the client)----*/
return response()->json(
[
"status" => true,
"message" => 'User account created successfully',
"data" => $user
],
201
);
}
Separating the Request Validation Concern
We will discuss two approaches to separate the Request validation concern from the Controller.
- The first approach involves building a request validation class and writing all your validation logic in the validation class. The Class needs to contain some
public
methods to ensure that the Controller can execute the validation logic. - The second approach is called Form request validation. This approach is specific to Laravel, and the validation class has to follow a particular pattern. It's a powerful feature.
Approach 1: Building a Request Validation Class
- Create a folder called
Validators
inside theapp/Http
directory - Create a new file called
AuthValidator.php
in the newly createdValidators
folder - The code below should be the content of the
AuthValidator.php
file
<?php
namespace App\Http\Validators;
use Illuminate\Support\Facades\Validator;
class AuthValidator
{
protected $errors = [];
/**
* validate user registration data
*
* @param array $requestData
* @return \App\Http\Validators\AuthValidator
*/
public function validateRegistrationData(array $requestData)
{
$validator = Validator::make($requestData, [
'email' => 'required|email|unique:users',
'name' => 'required|string',
'password' => 'required|min:6'
]);
$this->errors = $validator->errors();
return $this;
}
/**
* get the errors that occurred during
* validation
*
* @return array
*/
public function getErrors()
{
return $this->errors;
}
}
What exactly does the code do? π
- First, we have a property in the Class called
$errors
and set the value as an empty array - Then, we create a method called
validateRegisterationData(),
which will be responsible for executing the actual validation logic - The result of the validation check (
$validator->errors()
) is stored in the$errors
property of the current object. If all the data in the request are valid and no validation error(s) occurred, the validator returns an empty array. - Finally, we return the current object (
$this
) - The second method,
getErrors()
simply returns the$errors
property for the current object. This property would contain the actual errors that occurred during validation or an empty array if no errors occurred
How do we use the AuthValidator in the Controller? π€
Create a constructor method (__construct()
) in the AuthController class and pass the Authvalidator class as a parameter in the constructor. Then the value of the parameter is stored in a class property.
class AuthController extends Controller
{
protected $authValidator;
/**
* __construct
*
* @param \App\Http\Validators\AuthValidator $authValidator
* @return void
*/
public function __construct(AuthValidator $authValidator)
{
$this->authValidator = $authValidator;
}
Now we can remove the existing validation logic in the register()
method of the Controller and use the logic in the AuthValidator class we created βΊοΈ.
/**
* create a new account
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function register(Request $request)
{
$errors = $this->authValidator
->validateRegistrationData($request->all())
->getErrors();
if (count($errors)) {
return response()->json([ "status" => false,"message" => $errors],
422
);
}
Since the validateRegistrationData()
method returns an object, we can immediately execute the getErrors()
method on the returned object without assigning it to a variable first. This pattern is called Fluent interface. count($errors)
checks if there is at least one element in the errors array returned from the validator and returns a response to the client if any error exists.
With the code designed this way, the Controller does not care how the validation logic works. The Controller knows that the validator will do its job and return a response after completing the validation process. Then the final response is sent from the Controller. So we can add, remove or modify the validation logic, and the Controller still works perfectly without any issues. Sweet right. If you think this is great, let's look at the second method, which is using Laravel Form Requests.
Approach 2: Using Laravel Form Requests
Form requests are custom request classes containing their validation logic, just like we did in the first approach. Laravel Form requests are also awesome because they contain authorization logic by default. To create a form request class, you may use the make:request
artisan command provided by Laravel.
php artisan make:request CreateNewUserRequest
Laravel will generate a form request class in the app/Http/Requests
directory with the name passed as the file name. In our case, a new file, CreateNewUserRequest.php` will be created.
By default, Form request classes generated by Laravel come with two methods, authorize()
and rules()
. The authorize()
method contains the logic that determines if the user making that request has the permission to do so. Of course, you can modify the logic to fit your specific use case at any time. In our case, we do not require any special permission or authorization for the CreateNewUserRequest
Class, so we return true.
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize()
{
return true;
}
On the other hand, the rules()
method returns the validation rules that should apply to the request's data.
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return [
'email' => 'required|email|unique:users',
'name' => 'required|string',
'password' => 'required|min:6'
];
}
How do we use the rules in the CreateNewUserRequest class in the Controller? π€
- Change the type of the parameter in the register function from
\Illuminate\Http\Request
to\App\Http\Requests\CreateNewUserRequestwith
(this is the path to the Laravel form request class we just built) - Remove the validation logic in the
register()
function - Since Laravel internally checks for errors and returns an appropriate response when using Form requests, we do not need an
if
statement to check if the validation failed
/**
* create a new account
* @param App\Http\Requests\CreateNewUserRequest $request
* @return \Illuminate\Http\Response
*/
public function register(CreateNewUserRequest $request)
{
/*--------Concern 2 (Business logic execution)------------------*/
$password = Hash::make($request->password);
$user = User::create([
'name' => $request->name,
'password' => $password,
'email' => $request->email
]);
/*--------Concern 3(Response formatting and return)------------------*/
return response()->json(
[
"status" => true,
"message" => 'User account created',
"data" => $user
],
201
);
}
And voila! Our Controller has absolutely no idea about the validation logic, and changes to the validations rules do not affect the Controller. So once again, we can change the validation logic however we want, and the Controller will remain the same and work perfectly.
Let's explore Form Requests a little further πͺπΏ
Here is a sample error response from the CreateNewUserRequest
class after sending a request with an empty value as the name
of the new user
{
"message": "The given data was invalid.",
"errors": {
"name": [
"The name field is required."
]
}
}
Now, let's assume that due to our team's convention, we need to change the structure of the error response sent back to the client after a failed validation. We can achieve this by overriding the default failedValidation()
method in all Laravel form request classes that extend FormRequest
, just like our CreateNewUserRequest
Class.
Now we have an extra method, failedValidation()
, that looks like this.
/**
* Handle a failed validation attempt.
*
* @param \Illuminate\Contracts\Validation\Validator $validator
* @return void
*
* @throws \Illuminate\Validation\ValidationException
*/
protected function failedValidation(Validator $validator)
{
throw new HttpResponseException(
response()->json(
[
'status' => false,
'message' => 'Validation errors occurred',
'errors' => $validator->errors(),
],
422
)
);
}
After making our changes on how we want the error message to be structured, our response looks like this.
{
"status": false,
"message": "Validation errors occurred",
"errors": {
"name": [
"The name field is required."
]
}
}
What's next? π
In the next part of this series, we will be looking at how to use Actions
to separate business logic away from the Controller. Believe me, that would be awesome, and you don't want to miss it.
Quick recap π
- The controllers in your codebase should know what validation class to call to a particular request but have zero knowledge about the validation rules that apply to the request's data. That is the responsibility of Validators
- You can build your validator class or use Laravel form requests. I don't think any approach is better than the other. It all depends on whichever approach you prefer
- The Laravel form request class can also handle user authorization even before checking if the data from the client passes all the validation rules, and this is done via the
authorize()
method - The advantage of building your validators is that your methods can follow any naming convention you want. However, when using Laravel Form requests, the method handling the data validation must be named
rules
while the method that handles authorization must be namedauthorize
.
It's a wrap π
You can check out all the code in this article on Github. After reading this, I sincerely hope that you have learned something, no matter how small. If you did, kindly drop a thumbs up. Thanks for sticking with me till the end. If you have any suggestions or feedback, kindly drop them in the comment section. Enjoy the rest of your day. Bye π.