How to write clean Controllers in Laravel

How to write clean Controllers in Laravel

Beginner's Guide to clean code - Move your code where it belongs

ยท

7 min read

As a beginner Laravel developer - writing clean controllers is crucial. In my experience, I have come across a lot of laravel projects where the controllers are 1000s of lines long. It should not have to be this way.

Writing maintainable and efficient controllers isn't hard. You just have to know how to organize your codebase. In this article, we will explore essential tips to help you write cleaner controllers.

3 tips for cleaner laravel controllers :

  1. Smaller Methods with Single Responsibility

  2. Validation with Form Request Class

  3. Eloquent API Resources

We'll use a simple example of an e-commerce product management system to explain each point. So let's dive in!

1 - Smaller Methods with Single Responsibility

A controller method should have a single responsibility. Handling a specific HTTP request and returning the appropriate response.

You should avoid adding complex logic to your controller methods. Decouple the request-handling part of our application from the complex business logic. Your controller should only be responsible for "controlling" the flow of execution.

Let's understand this with an example :

class ProductController extends Controller
{
    // ...

    public function update(UpdateProductRequest $request, $id)
    {
        $product = Product::findOrFail($id);

        $data = $request->validated();

        $product = Product::findOrFail($id);

        if ($request->hasFile('image')) {
            $image = $request->file('image');
            $imagePath = 'uploads/products/' . $image->hashName();
            $image->storeAs('public', $imagePath);
            $product->image = $imagePath;
        }

        $product->name = $request->input('name');
        $product->price = $request->input('price');
        $product->category_id = $request->input('category_id');
        $product->description = $request->input('description');
        $product->save();

        return redirect()->route('products.index')->with('success', 'Product updated successfully.');
    }

    // ...
}

Here update method here is responsible for multiple things. It also handles file uploads and storing images which is not its primary concern. It should only focus on 'updating' product data.

Let's see how we can clean up our update method

class ProductController extends Controller
{
    private $productService;

    public function __construct(ProductService $productService)
    {
        $this->productService = $productService;
    }

    // ...

    public function update(UpdateProductRequest $request, $id): RedirectResponse
    {
        $product = Product::findOrFail($id);

        $data = $request->validated();

        if ($request->hasFile('image')) {
            $image = $request->file('image');
            $data['image'] = $this->productService->storeProductImage($image);
        }

        $this->productService->updateProduct($product, $data);

        return redirect()->route('products.index')->with('success', 'Product updated successfully.');
    }
}

Note that we still have to update the Product model and upload the image. But we do that in a separate class ProductService that handles these specific operations.

Create Service Classes or Actions to handle complex business logic

This is a very simple example but you can imagine a more complex feature that involves probably 5 different operations based on the request and model. It can get very complicated very quickly.

Key Takeaways :

  • Stick to a single responsibility for each controller action

  • Keep your methods focused and concise, performing a specific task

  • Break down complex operations into smaller, reusable methods

2 - Validation with Form Request Class

Validating incoming request data is essential for ensuring data integrity and security. But if you are not careful - the validation logic can get complex making code unreadable.

You can create custom form request classes to keep your controllers clean. This separates validation logic from the controller itself. Laravel provides a great way to overcome this - Request classes.

Let's explore this. First, consider store method of the ProductController. This is your typical validation logic implemented within the controller action.

use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use App\Models\Product;

class ProductController extends Controller
{
    public function store(Request $request)
    {
        // Validate the request data
        $validatedData = $request->validate([
            'name' => 'required|string|max:255',
            'description' => 'required|string',
            'price' => 'required|numeric|min:0',
            'quantity' => 'required|integer|min:0',
            'category_id' => 'required|exists:categories,id',
            'brand_id' => 'required|exists:brands,id',
            'color' => 'required|string|max:50',
            'size' => 'required|string|max:20',
            'weight' => 'required|numeric|min:0',
            'images' => 'required|array',
            'images.*' => 'required|image|max:2048',
        ]);

        // Create a new product ...        
    }
}

There are a few disadvantages of doing validation this way :

  1. Code bloating and decreased readability

  2. Code duplication across controller actions

  3. Testing challenges and reduced testability

So let's see how you can rewrite this using a Request class and make it cleaner, reusable, and more testable

Creating a request class using the Artisan command:

php artisan make:request CreateProductRequest

This will generate a new CreateProductRequest class in the app/Http/Requests directory. Open the generated file and update its rules method with the validation rules:

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class CreateProductRequest extends FormRequest
{
    public function rules()
    {
        return [
            'name' => 'required|string|max:255',
            'description' => 'required|string',
            'price' => 'required|numeric|min:0',
            'quantity' => 'required|integer|min:0',
            'category_id' => 'required|exists:categories,id',
            'brand_id' => 'required|exists:brands,id',
            'color' => 'required|string|max:50',
            'size' => 'required|string|max:20',
            'weight' => 'required|numeric|min:0',
            'images' => 'required|array',
            'images.*' => 'required|image|max:2048',
        ];
    }
}

Now, in your ProductController you can use the new request class like so:

use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use App\Http\Requests\CreateProductRequest;
use App\Models\Product;

class ProductController extends Controller
{
    public function store(CreateProductRequest $request)
    {
        // Retrieve the validated data
        $validatedData = $request->validated();

        // Create a new product ...
    }
}

It's a good idea to reuse the request class for different controller actions if possible. But if you want you can also create a different request class for update action. You can create as many custom request classes as needed.

Key Takeaways:

  • Separate validation logic from your controller using custom form requests.

  • Reuse the request class in other controllers/methods

3 - Eloquent API Resources

When you are writing controllers for your API it is very common to format the JSON response.

If the response format that you need is different than your schema, the formatting logic can also make your controllers bloated and hard to read. And sometimes you may want to reuse the same response format in multiple API endpoints.

This is where you can leverage Eloquent API Resources to keep your responses consistent, make your formats reusable and your controllers clean

Let's look at an example of an API response that is formatted within the controller method:

use Illuminate\Http\Request;
use Illuminate\Http\Response;

class ProductController extends Controller
{
    public function index(Request $request)
    {
        $products = Product::with('brand', 'categories', 'features')->get();

        // Transform the product list with required attributes
        $transformedProducts = $products->map(function ($product) {
            return [
                'id' => $this->id,
                'name' => $this->name,
                'price' => $this->price,
                'description' => $this->description,
                'brand_name' => $product->brand->name,
                'brand_image' => $product->brand->image,
                'categories' => $product->categories->map(function ($category) {
                    return [
                        'name' => $category->name,
                        'alias' => $category->alias,
                        'image' => $category->image,
                    ];
                }),
                'features' => $product->features->map(function ($feature) {
                    return [
                        'title' => $feature->title,
                        'description' => $feature->description
                    ];
                })
            ];
        });

        return response()->json(['products' => $transformedProducts], Response::HTTP_OK);
    }
}

Now let's look at how can we extract this logic and put it in a Resource class

First, create ProductResource class

php artisan make:resource Product

Let's move the logic to this class

use Illuminate\Http\Resources\Json\JsonResource;

class ProductResource extends JsonResource
{
    public function toArray($request)
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'price' => $this->price,
            'description' => $this->description,
            'brand_name' => $this->brand->name,
            'brand_image' => $this->brand->image,
            'categories' => $this->categories->map(function ($category) {
                return [
                    'name' => $category->name,
                    'alias' => $category->alias,
                    'image' => $category->image,
                ];
            }),
            'features' => $this->features->map(function ($feature) {
                return [
                    'title' => $feature->title,
                    'description' => $feature->description
                ];
            })
        ];
    }
}

Now we can use this in our controller

use Illuminate\Http\Request;
use Illuminate\Http\Response;
use App\Http\Resources\ProductResource;

class ProductController extends Controller
{
    public function index(Request $request)
    {
        $products = Product::with('brand', 'categories', 'features')->get();

        $productResource = ProductResource::collection($products);

        return response()->json(['products' => $productResource], Response::HTTP_OK);
    }
}

As you can see with this approach our controller is much cleaner compared to the original index method. It is also reusable. Let's say you have a customer orders endpoint where want to provide the products along with an order object (a very common API requirement) you can reuse the same resource/collection

Key Takeaways:

  • Use the Eloquent API Resources to keep controllers more readable

  • Improve the reusability and maintainability of your code by separating data transformation logic from your controllers


Writing clean controllers in Laravel is essential for building maintainable and efficient applications. Get into the habit of writing cleaner code and your life as a developer will be much easier. As with all good habits, it will take deliberate thinking and practice.

By practicing these principles and incorporating them into your development workflow, you'll be well on your way to writing cleaner and more organized controllers in Laravel.

I have also published a blog on getting started with background jobs in laravel

I hope you find this valuable, if you did - awesome ๐Ÿ‘Œ share it with your folks if it's relevant to them. If you have any suggestions/comments please feel free.

Happy coding!

ย