Controllers and models becoming overly complex are common issues in large-scale projects using MVC frameworks like Yii and Laravel. A major contributor is the Active Record, a crucial component of these frameworks.
The Problem: Active Records and Violating the SRP
The Active Record pattern, as described by Martin Fowler in his 2003 book Patterns of Enterprise Application Architecture, is a way to access database data and is widely used in PHP frameworks.
Despite its necessity, the Active Record (AR) pattern violates the Single Responsibility Principle (SRP) because AR models:
- Handle both querying and saving data.
- Possess excessive knowledge about other models through relationships.
- Often become entangled with business logic due to the close link between data storage and business rules.
This SRP violation is acceptable for rapid prototyping but detrimental as an application scales. “God” models and bulky controllers become difficult to test and maintain. Using models directly in controllers leads to significant challenges when database changes are needed.
The solution is to separate Active Record responsibilities into layers and manage dependencies through injection. This also simplifies testing by allowing mocking of unrelated layers.
A Layered Structure for PHP MVC Frameworks
Unlike a tightly coupled, error-prone “fat” PHP MVC application, a layered structure uses dependency injection for organization.
We’ll cover five key layers:
- The controller layer
- The service layer
- DTOs within the service layer
- View decorators within the service layer
- The repository layer

Implementing a layered structure requires a dependency injection container, an object responsible for object instantiation and configuration, which the framework usually provides. Consider this:
| |
Here, UserService is injected into SiteController, UserRepository into UserService, and AR models User and Logs into UserRepository. With the container code explained, let’s discuss the layers.
The Controller Layer
Modern frameworks like Laravel and Yii handle many traditional controller tasks. Input validation and pre-filtering are managed by middleware (Laravel) or behavior (Yii), while routing and HTTP verb handling are framework responsibilities. This leaves a focused set of functionalities for developers to implement in controllers.
A controller’s core function is to receive requests and return results. It should not contain business logic, as this hinders code reuse and adaptability. For instance, switching from rendered views to an API becomes simpler when controllers solely manage data return formats.
This streamlined controller approach often confuses developers. As the default entry point, controllers tend to accumulate code without architectural consideration, leading to an excess of responsibilities, such as:
- Handling business logic, making reuse impossible.
- Directly altering model states, creating a ripple effect of changes throughout the codebase when the database changes.
- Managing model relation logic like complex queries and model joins, leading to widespread changes if the database or relation logic is modified.
Here’s an example of an overly complex controller:
| |
This example is problematic for several reasons:
- Excessive business logic.
- Direct interaction with Active Record, requiring changes in all controllers if a database field like
last_loginis renamed. - Knowledge of database relations, necessitating changes across the application if database modifications occur.
- Lack of reusability, leading to code duplication.
Controllers should be minimal, focusing on receiving requests and returning results. Here’s an improved example:
| |
The additional logic belongs in the service layer.
The Service Layer
The service layer houses business logic, defining business process flows and interactions between business models. This abstract layer varies between applications but remains independent of data sources (controller responsibility) and data storage (handled by a lower layer).
This layer is prone to scalability issues. Returning an Active Record model directly to the controller forces the view (or API response) to interact with and depend on the model’s attributes and dependencies. This creates a maintenance nightmare; changing an attribute or relation in an Active Record model necessitates changes in all related views and controllers.
Consider this common example of an Active Record model used in a view:
| |
While simple, renaming the first_name field would require changes in all views using it, increasing the potential for errors. Using data transfer objects (DTOs) addresses this.
Data Transfer Objects
Data from the service layer should be encapsulated in simple, immutable objects (DTOs) without setters. DTO classes should be independent, not extending Active Record models. Importantly, a business model might differ from an AR model.
Imagine a grocery delivery application. A grocery store order logically includes delivery details. However, the database might store orders and users separately, linking them to delivery addresses. Here, multiple AR models exist, but upper layers shouldn’t be concerned with them. The DTO would include order details, delivery information, and other relevant aspects aligned with the business model. Changes in related AR models (e.g., moving delivery information to the order table) would only affect field mapping in the DTO, not application-wide code changes.
DTOs prevent modification of Active Record models in controllers or views. They also decouple physical data storage from the logical representation of the business model. Database changes only impact the DTO, not controllers and views. Notice a trend?
Let’s look at a basic DTO:
| |
Using our DTO is straightforward:
| |
View Decorators
Separating view logic (like styling a button based on status) is best achieved using decorators. A decorator is a design pattern that adds functionality to an object by wrapping it with custom methods. It is often used in views for specific logic.
While DTOs can handle some decoration, they primarily represent business models. Decorators enhance data with HTML tailored for specific pages.
Consider a user profile status icon without a decorator:
| |
This simple example can become complex quickly. A decorator improves HTML readability. Let’s create a decorator class for the status icon:
| |
Using the decorator is simple:
| |
Now, model attributes can be used in the view without conditional logic, enhancing readability:
| |
Decorators can be combined:
| |
Each decorator handles its part without requiring additional classes.
The Repository Layer
The repository layer interacts with the concrete data storage implementation. Injecting it through an interface provides flexibility and easy replacement. If the data storage changes, a new repository implementing the interface can be created without modifying other layers.
Acting as a query object, the repository retrieves data and manages multiple Active Record models. These models represent individual data entities. Entities contain information but are unaware of their origin or persistence mechanisms. The repository handles saving and updating, separating concerns and simplifying entities.
Here’s an example of a repository method using database knowledge and Active Record relations:
| |
Staying Lean with Single Responsibility Layers
New applications typically start with folders for controllers, models, and views. Frameworks like Yii and Laravel don’t enforce additional layers in their sample applications. While this MVC structure is simple and beginner-friendly, it’s crucial to understand that these examples aren’t standards.
Dividing tasks into distinct layers with single responsibilities leads to a flexible, maintainable architecture. Remember:
- Entities are single data models.
- Repositories fetch and prepare data.
- The service layer houses business logic.
- Controllers handle external interactions like user input or third-party services.
For complex projects or those with growth potential, consider separating responsibilities into controllers, services, and repositories.