Web front-end
9 minute read

Maintain Slim PHP MVC Frameworks with a Layered Structure

Elvira is a senior software engineer with problem-solving and analytical skills and over 12 years of experience in web development.

Fat controllers and models: an inevitable problem for most large-scale projects based on MVC frameworks such as Yii and Laravel. The primary thing that fattens controllers and models is the Active Record, a powerful and essential component of such frameworks.

The Problem: Active Records and Its Violation of the SRP

The Active Record is an architectural pattern, an approach to accessing data in a database. It was named by Martin Fowler in his 2003 book Patterns of Enterprise Application Architecture and is widely used in PHP Frameworks.

Despite the fact that it is a very necessary approach, the Active Record (AR) pattern violates the Single Responsibility Principle (SRP) because AR models:

  • Deal with querying and data saving.
  • Know too much about the other models in the system (through relationships).
  • Are often directly involved in the application’s business logic (because the implementation of data storage is closely linked to said business logic).

This violation of the SRP is a good tradeoff for rapid development when you need to create an application prototype as soon as possible, but it is quite harmful when the application grows into a middle or a large-scale project. “God” models and fat controllers are difficult to test and maintain, and freely using models everywhere in controllers leads to tremendous difficulties when you inevitably have to change the database structure.

The solution is simple: divide the Active Record’s responsibility into several layers and inject cross-layer dependencies. This approach will also simplify testing because it allows you to mock those layers not currently being tested.

The Solution: A Layered Structure for PHP MVC Frameworks

A “fat” PHP MVC application has dependencies everywhere, interlocking and error-prone, while a layered structure uses dependency injection to keep things clean and clear cut.

There are five primary layers that we’ll cover:

  • The controller layer
  • The service layer
  • DTOs, a subset of the service layer
  • View decorators, a subset of the service layer
  • The repository layer

A Layered PHP Structure

To implement a layered structure, we need a dependency injection container, an object that knows how to instantiate and configure objects. You don’t need to create a class because the framework handles all the magic. Consider the following:

class SiteController extends \Illuminate\Routing\Controller
{
   protected $userService;

   public function __construct(UserService $userService)
   {
       $this->userService = $userService;
   }

   public function showUserProfile(Request $request)
   {
       $user = $this->userService->getUser($request->id);
       return view('user.profile', compact('user'));
   }
}

class UserService
{
   protected $userRepository;

   public function __construct(UserRepository $userRepository)
   {
       $this->userRepository = $userRepository;
   }

   public function getUser($id)
   {
       $user = $this->userRepository->getUserById($id);
       $this->userRepository->logSession($user);
       return $user;
   }
}

class UserRepository
{
   protected $userModel, $logModel;

   public function __construct(User $user, Log $log)
   {
       $this->userModel = $user;
       $this->logModel = $log;
   }

   public function getUserById($id)
   {
       return $this->userModel->findOrFail($id);
   }

   public function logSession($user)
   {
       $this->logModel->user = $user->id;
       $this->logModel->save();
   }
}

In the above example, UserService is injected into SiteController, UserRepository is injected into UserService and the AR models User and Logs are injected into the UserRepository class. This container code is fairly straightforward, so let’s talk about the layers.

The Controller Layer

Modern MVC frameworks like Laravel and Yii take on many of the traditional controller challenges for you: Input validation and pre-filters are moved to another part of the application (In Laravel, it’s in what’s called middleware whereas, in Yii, it’s called behavior) while routing and HTTP verb rules are handled by the framework. This leaves a very narrow functionality for the programmer to code into a controller.

The essence of a controller is to get a request and deliver the results. A controller shouldn’t contain any application business logic; otherwise, it’s difficult to reuse code or change how the application communicates. If you need to create an API instead of rendering views, for example, and your controller doesn’t contain any logic, you just change the way you return your data and you’re good to go.

This thin controller layer often confuses programmers, and, since a controller is a default layer and the top-most entry point, many developers just keep adding new code to their controllers without any additional thinking about architecture. As a result, excessive responsibilities get added, responsibilities like:

  • Business logic (which it makes impossible to reuse business logic code).
  • Direct changes of model states (in which case any changes in the database would lead to tremendous changes everywhere in the code).
  • Model relation logic (such as complex queries, joining of multiple models; again, if something is changed in the database or in the relation logic, we would have to change it in all controllers).

Let’s consider an over-engineered controller example:

//A bad example of a controller
public function user(Request $request)
{
   $user = User::where('id', '=', $request->id)
   ->leftjoin('posts', function ($join) {
       $join->on('posts.user_id', '=', 'user.id')
           ->where('posts.status', '=', Post::STATUS_APPROVED);
   })
   ->first();
   if (!empty($user)) {
       $user->last_login = date('Y-m-d H:i:s');
   } else {
       $user = new User();
       $user->is_new = true;
       $user->save();
   }
   return view('user.index', compact('user'));
}

Why is this example bad? For numerous reasons:

  • It contains too much business logic.
  • It works with the Active Record directly, so if you change something in the database, like rename the last_login field, you have to change it in all controllers.
  • It knows about database relations, so if something changes in database we have to change it everywhere.
  • It’s not reusable, leading to code repetition.

A controller should be thin; really, all it should do is take a request and return results. Here’s a good example:

//A good example of a controller
public function user (Request $request)
{
 $user = $this->userService->getUserById($request->id);
 return view('user.index', compact('user'));
}

But where does all that other stuff go? It belongs in the service layer.

The Service Layer

The service layer is a layer of business logic. Here, and only here, information about business process flow and interaction between the business models should be situated. This is an abstract layer and it will be different for each application, but the general principle is independence from your data source (the responsibility of a controller) and data storage (the responsibility of a lower layer).

This is the stage with the most potential for growth problems. Often, an Active Record model is returned to a controller, and as a result, the view (or in the case of API response, the controller) must work with the model and be aware of its attributes and dependencies. This makes things messy; if you decide to change a relation or an attribute of an Active Record model, you have to change it everywhere in all your views and controllers.

Here’s a common example you might come across of an Active Record model being used in a view:

<h1>{{$user->first_name}} {{$user->last_name}}</h1>
<ul>
@foreach($user->posts as $post)
<li>{{$post->title}}</li> 
@endforeach
</ul>

It looks straightforward, but if I rename the first_name field, suddenly I have to change all views that use this model’s field, an error-prone process. The easiest way to avoid this conundrum is to use data transfer objects, or DTOs.

Data Transfer Objects

Data from the service layer needs to be wrapped into a simple immutable object—meaning it can’t be changed after it is created—so we don’t need any setters for a DTO. Furthermore, the DTO class should be independent and not extend any Active Record models. Careful, though—a business model is not always the same as an AR model.

Consider a grocery delivery application. Logically, a grocery store order needs to include delivery information, but in the database, we store orders and link them to a user, and the user is linked to a delivery address. In this case, there are multiple AR models, but the upper layers shouldn’t know about them. Our DTO class will contain not only the order but also delivery information and any other parts that are in line with a business model. If we change AR models related to this business model (for example, we move delivery information into the order table) we will change only field mapping in the DTO object, rather than changing your usage of AR model fields everywhere in the code.

By employing a DTO approach, we remove the temptation to change the Active Record model in the controller or in the view. Secondly, the DTO approach solves the problem of connectivity between the physical data storage and the logical representation of an abstract business model. If something needs to be changed on the database level, the changes will affect the DTO object rather than the controllers and views. Seeing a pattern?

Let’s take a look at a simple DTO:

//Example of simple DTO class. You can add any logic of conversion from an Active Record object to business model here 
class DTO
{
   private $entity;

   public static function make($model)
   {
       return new self($model);
   }

   public function __construct($model)
   {
       $this->entity = (object) $model->toArray();
   }

   public function __get($name)
   {
       return $this->entity->{$name};
   }

}

Using our new DTO is just as straightforward:

//usage example
public function user (Request $request)
{
 $user = $this->userService->getUserById($request->id);
 $user = DTO::make($user);
 return view('user.index', compact('user'));
}

View Decorators

For separating view logic (like choosing a button’s color based on some status), it makes sense to use an additional layer of decorators. A decorator is a design pattern that allows embellishment of a core object by wrapping it with custom methods. It usually happens in the view with a somewhat special piece of logic.

While a DTO object can perform a decorator’s job, it really only works for common actions like date formatting. A DTO should represent a business model, whereas a decorator embellishes data with HTML for specific pages.

Let’s look at a snippet of a user profile status icon that doesn’t employ a decorator:

<div class="status">
   @if($user->status == \App\Models\User::STATUS_ONLINE)
       <label class="text-primary">Online</label>
   @else
       <label class="text-danger">Offline</label>
   @endif   
</div>
<div class="info"> {{date('F j, Y', strtotime($user->lastOnline))}} </div>        

While this example is straightforward, it’d be easy for a developer to get lost in more complicated logic. This is where a decorator comes in, to clean up the HTML’s readability. Let’s expand our status icon snippet into a full decorator class:

class UserProfileDecorator
{
   private $entity;

   public static function decorate($model)
   {
       return new self($model);
   }

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

   public function __get($name)
   {
       $methodName = 'get' . $name;
       if (method_exists(self::class, $methodName)) {
           return $this->$methodName();
       } else {
           return $this->entity->{$name};
       }
   }

   public function __call($name, $arguments)
   {
       return $this->entity->$name($arguments);
   }

   public function getStatus()
   {
       if($this->entity->status == \App\Models\User::STATUS_ONLINE) {
           return '<label class="text-primary">Online</label>';
       } else {
           return '<label class="text-danger">Offline</label>';
       }
   }

   public function getLastOnline()
   {
       return  date('F j, Y', strtotime($this->entity->lastOnline));
   }
}

Using the decorator is easy:

public function user (Request $request)
{
 $user = $this->userService->getUserById($request->id);
 $user = DTO::make($user);
 $user = UserProfileDecorator::decorate($user);
 return view('user.index', compact('user'));
}

Now we can use model attributes in the view without any conditions and logic, and it’s much more readable:

<div class="status"> {{$user->status}} </div>    
<div class="info"> {{$user->lastOnline}} </div>

Decorators also can be combined:

public function user (Request $request)
{
 $user = $this->userService->getUserById($request->id);
 $user = DTO::make($user);
 $user = UserDecorator::decorate($user);
 $user = UserProfileDecorator::decorate($user);
 return view('user.index', compact('user'));
}

Each decorator will do its job and decorate only its own part. This recursive embedding of several decorators allows for a dynamic combination of their features without introducing additional classes.

The Repository Layer

The repository layer works with the concrete implementation of data storage. It’s best to inject the repository through an interface for flexibility and easy replacement. If you change your data storage, you have to create a new repository that implements your repository interface, but at least you don’t have to change the other layers.

The repository plays the role of a query object: It gets data from the database and conducts the work of several Active Record models. Active Record models, in this context, play the role of single data model entities—any object in the system that you care to model and store information about. While each entity contains information, it doesn’t know how it appeared (if it was created or obtained from the database), or how to save and change its own state. The responsibility of the repository is to save and/or update an entity; this provides better separation of concerns by keeping management of entities in the repository and making entities simpler.

Here’s a straightforward example of a repository method that builds a query using knowledge about the database and Active Record relations:

public function getUsers()
{
return User::leftjoin('posts', function ($join) {
    $join->on('posts.user_id', '=', 'user.id')
        ->where('posts.status', '=', Post::STATUS_APPROVED);
   })
   ->leftjoin('orders', 'orders.user_id', '=', 'user.id')
   ->where('user.status', '=', User::STATUS_ACTIVE)
   ->where('orders.price', '>', 100)
   ->orderBy('orders.date')
   ->with('info')
   ->get();
}

Keeping Slim with Single Responsibility Layers

In a newly created application, you’ll only find folders for сontrollers, models, and views. Neither Yii nor Laravel add additional layers in their example application’s structure. Easy and intuitive, even for novices, the MVC structure simplifies work with the framework, but it is important to understand that their sample application is an example; it isn’t a standard or a style, and it doesn’t impose any rules about application architecture. By dividing tasks into separate, single responsibility layers, we get a flexible and extensible architecture that is easy to maintain. Remember:

  • Entities are single data models.
  • Repositories fetch and prepare data.
  • The service layer has only business logic.
  • Controllers communicate with all external sources like user input or a 3rd party service.

So if you start a complex project or a project that has a chance to grow in the future, consider a clear division of responsibilities into the controller, the service, and the repository layers.