DEV Community

Cover image for How to Write Scalable and Testable Laravel Production App
Ikechukwu Vincent
Ikechukwu Vincent

Posted on

How to Write Scalable and Testable Laravel Production App

I have been working full-time as a software engineer for a little over three years. During this period I have noticed a couple of things:

  1. Microservice is an overkill in most instances where folks use it.
  2. Most monolith code bases aren't scalable or maintainable.

But what does it mean for your code to be scalable? It involves a couple of things, for this article I will give a simplified but important definition: scalability is the ability of your code to accommodate more requirements and features without breaking or boxing the developer into a corner.

Insights I will share in this article can help you write more maintainable and scalable monolith code and even better micro-services if you must write one. While I will use Laravel as an example, this article applies to any MVC-based framework. Shall we?

Writing scalable and maintainable code begins with understanding the limitations inherent in your framework's software architecture - and that would be MVC in the case of Laravel.
To understand the limitations of MVC, read this article from Uber about their engineering challenges: https://www.uber.com/en-NG/blog/new-rider-app-architecture/

Here is the catch:

First, matured MVC architectures often face the struggles of massive view controllers. For instance, the RequestViewController, which started off as 300 lines of code, is over 3,000 lines today due to handling too many responsibilities: business logic, data manipulation, data verification, networking logic, routing logic, etc. It has become hard to read and modify.

This whole thing is about making your controller as lean as possible. What are the things that mess up your controller?

  • Validation logic
  • Business logic
  • Data manipulation and redirection
  • API vs Web request handling
  • Non-crude controllers

Validation Logic

For every form submission create a new Request class for form validation.

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

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

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array|string>
     */
    public function rules(): array
    {
        return [
            'body' => 'required|string',
            'rating' => 'required|string',
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

Use it as follows inside the reviews controller:

   public function store(StoreReviewRequest $request)
    {
        return DB::transaction(function () use ($request) {
            $this->service->storeUserRestaurantReview($request->all());
            Session::flash('success', 'Thank you for leaving a review');

            return redirect()->back();
        });
    }

Enter fullscreen mode Exit fullscreen mode

The magic above may not seem like a lot until the number of fields you want to validate increases to a certain number. Get more context here.

Business Logic

You will write lots of logic and manipulate lots of data, for the interest of maintainability and scalability, you should do this in a service class - this is called Service Pattern. Create a folder named "Services", subfolder it if necessary, or even version it for API. Inside this folder, write your services. Now take a second look at the code above you will see where I imported the ReviewService class. Inside the class, you can write all kinds of methods you need for review.
Most of your business logic should be in service classes.

However, some that have to do with precisely data retrieval or storage can be in your models.

By following this, you have small pieces of code that can be tested via unit tests and integration tests. That my friend is your code becoming maintainable.

If necessary you could also use a repository pattern. Create a folder in the App folder called "Repository" and in a file named "BaseRepositoryInterface:

<?php
namespace App\Repository;

//use Illuminate\Datatbase\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Collection;

interface EloquentRepositoryInterface{
  public function all(array $column =['*'], array $relations=[]):Collection;

  public function findById(
    int $modelid,
    array $column = ['*'],
    array $relations = [],
    array $appends = []
  ):?Model;

  public function create(array $payload):?Model;

  public function update(int $modelid, array $payload):bool;

  // public function restoryById(int $modelid):bool;

  //public function attach()
}
Enter fullscreen mode Exit fullscreen mode

Inside the repository, create a folder called Eloquent, inside a file called BaseRepository:

<?php
namespace App\Repository\Eloquent;

use App\Repository\EloquentRepositoryInterface;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;

class BaseRepository implements EloquentRepositoryInterface{

  protected $model;

  public function __construct(Model $model)
  {
    $this->model = $model; 
  }

  public function all(array $column = ['*'], array $relations =[]):collection{
    return $this->model->with($relations)->get($column);
  }

  public function findById(
    int $modelId,
    array $column = [],
    array $relations = [],
    array $append = []
  ):?Model{
    return $this->model->select($column)->with($relations)->findOrFail($modelId)->append($append);
  }

  public function create(array $payload):?Model{
    $model = $this->model->create($payload);
    return $model->fresh;
  }

  public function update(int $modelId, array $payload):bool{
    $model = $this->findById($modelId);
    return $model->update($payload);
  }

  // public function findById(

  // )

    public function attach(){

    }

}
Enter fullscreen mode Exit fullscreen mode

Now we have a MoviesRepository file inside Repository/Eloquent

<?php 
namespace App\Repository\Eloquent;
//namespace App\Repository\Eloquent;

use App\Models\Movie;
use App\Repository\MovieRepositoryInterface;
use App\Repository\UserRepositoryInterface;

class MovieRepository extends BaseRepository implements MovieRepositoryInterface{
  protected $model;

  public function __construct(Movie $model){
    $this->model = $model;
  }
}
Enter fullscreen mode Exit fullscreen mode

You can see it extends an interface inside the repository folder, here is the code:

<?php 

namespace App\Repository;

interface MovieRepositoryInterface extends EloquentRepositoryInterface{};
Enter fullscreen mode Exit fullscreen mode

Now let us see how to use this code:

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Repository\MovieRepositoryInterface;
use Illuminate\Support\Facades\Auth;
use App\Repository\LocationRepositoryInterface;
use Illuminate\Support\Facades\Session;
class MoviesController extends Controller
{

    private $movieRepository;
    private $locationRepository;


    public function __construct(MovieRepositoryInterface $movieRepository){
        $this->movieRepository = $movieRepository;
    }

    public function create(LocationRepositoryInterface $locationRepository){

        $this->locationRepository = $locationRepository;
        $locations = $this->locationRepository->all();

        return view('pages.movies.create')->with('locations', $locations);
    }

    public function store(Request $request){
        // I AM JUMPING FIELD VALIDATION AND PURIFICATION STEP HERE.
        $id = Auth()->user()->id;
        $payload = [
            'name'=>$request->title, 
            'descript'=>$request->description,
            'poster'=>$request->poster,
            'showtime'=>$request->showtime,
            'location'=>$request->location,
            'userid'=>$id
        ];

        // var_dump($payload);die;
        $this->movieRepository->create($payload);
        Session::flash('success', "New movie Created");
        return redirect()->route('new_movie');
    }
}
Enter fullscreen mode Exit fullscreen mode

For the above to work, you must register Repository Provider as follows:

<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use App\Repository\EloquentRepositoryInterface;
use App\Repository\Eloquent\BaseRepository;
use App\Repository\MovieRepositoryInterface;
use App\Repository\Eloquent\LocationRepository;
use App\Repository\Eloquent\MovieRepository;
use App\Repository\LocationRepositoryInterface;

class RepositoryServiceProvider extends ServiceProvider
{
    /**
     * Register services.
     *
     * @return void
     */
    public function register()
    {
        $this->app->bind(EloquentRepositoryInterface::class, BaseRepository::class);
        $this->app->bind(MovieRepositoryInterface::class, MovieRePository::class);
        $this->app->bind(LocationRepositoryInterface::class, LocationRepository::class);
    }

    /**
     * Bootstrap services.
     *
     * @return void
     */
    public function boot()
    {
        //
    }
}
Enter fullscreen mode Exit fullscreen mode

Now inside AppServiceProvider you must register repository service provider as follows:

<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Schema;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     *
     * @return void
     */
    public function register()
    {
        $this->app->register(RepositoryServiceProvider::class);
    }

    /**
     * Bootstrap any application services.
     *
     * @return void
     */
   public function boot(){
    // Fix for MySQL < 5.7.7 and MariaDB < 10.2.2
   Schema::defaultStringLength(191);
}
}
Enter fullscreen mode Exit fullscreen mode

Repository pattern introduces more abstraction into your code as such I think you should not use it unless it's totally necessary.

API vs Web Request Handling

You can use the same controllers for web and API but I recommend you use different controllers for web and API and have them share the code in the service class. Now the service class needs to be versioned. This is keeping up with DRY.

Non-crude Controllers

Ideally, your controllers should be pure crude - create, read, update, edit but some devs define other kinds of method in there. Any method that cannot fit into these should be in an invokable controller. Keep your controllers crude or invokable.

Invokable controllers are controllers with one method __invokable() that executes when the controller is called.

Beyond Framework Limitations

I mentioned earlier about understanding the limitations of the software architecture your framework of choice uses. Beyond that, you also need to understand the functionalities available in your framework so you don't have to reinvent the wheel while using the framework. For instance, laravel has many ways of authorization who sees and acts on a resource. A deep understanding of them like gates, policies, spatie permissions, and co will save tons of lines of unnecessary code and gnashing of teeth.

Tests

With the few points above you can proceed to add unit and integration tests to your code to keep track of things each time you make a change.

Database Integrity

For things not to blow apart and also to avoid encountering funny edge case malfunctions in your application, I strictly recommend you enforce data integrity with military precision. First, use foreign keys in your relationships so it would be illegal to delete some resources.

Use database transactions whenever you are writing to more than one table, this keeps your data consistent across tables.

Conclusion

With the few points we have discussed in this article, your code will be very maintainable and scalable. It will be easy to modify existing features or add a new one without breaking things. This approach will make your monolith application incredibly maintainable and scalable. If you are building microservices, this approach will help to keep things a lot saner.

I am Ikechukwu Vincent a Full Stack Software Engineer proficient in PHP, Python, NodeJs, and React. I specialize in building B2B Multi-tenancy applications.

Connect with me on Socials

Top comments (0)