Best Practices for MVC Architecture in PHP: Common Mistakes and How to Avoid Them – Part 1: Controllers

Best Practices for MVC Architecture in PHP: Common Mistakes and How to Avoid Them – Part 1: Controllers

By on Tue Apr 15 in MVC Architecture, PHP, Refactoring


0
(0)

Summary

  1. Introduction
  2. MVC in PHP
  3. Why This Article?
  4. Common Mistake: Mixing Responsibilities
  5. Understanding the Roles of Model, View, and Controller
  6. Roles and Best Practices
  7. Benefits of Respecting Responsibilities
  8. Where to Write Business Logic
  9. Service Classes: The Advantages
  10. Keep Controllers Slim: S.O.L.I.D. and Single Action Controller
  11. Conclusion

Introduction

The Model-View-Controller (MVC) architecture is one of the oldest and most popular standard architectural patterns in the industry. It was invented by Trygve Reenskaug in the late 1970s. Today, it remains one of the most widely used approaches in web application development, thanks to its ability to separate concerns and improve code management.

In an MVC application, the (M)odel focuses on handling data, the (V)iew is responsible for presenting that data, and the (C)ontroller manages user interactions and coordinates application logic. This separation allows for more modular, maintainable, and scalable applications.

MVC in PHP

In PHP, major frameworks like Laravel and Symfony fully embrace the principles of the MVC architecture to organize code in a clear and effective way.

However, despite its popularity, correctly implementing the MVC pattern is not always straightforward.

I’ve already written an article on MVC and how you can easily build a simple MVC framework in PHP.

Why This Article?

I want to make it clear that I’m writing this article based on my personal experience. I’ve worked at several companies where PHP was the main language. Some of these companies even tried transitioning to more modern technologies like Go, but ended up managing two teams: one handling the new Go-based infrastructure, and another still working with PHP, for both new features and the legacy project. But business demands never stop, and there’s never enough time to refactor those massive 2,000-3,000 line classes.

There’s no time because business always comes first. And then there are new developers to onboard, the daily standups, onboarding sessions, and the usual coffee breaks. And of course, meetings and team-building activities.

Messy, disorganized, and hard-to-maintain codebases—especially when management overlooks improvement—quickly drain developers’ motivation to contribute meaningful value. And a lack of passion in our field usually means serious trouble down the road.

In this article, we’ll explore some common mistakes developers make when implementing MVC architecture in PHP, and I’ll share best practices to improve code quality while keeping the architecture clean and scalable. The goal is to help developers avoid frequent pitfalls and build more solid, maintainable applications that are ready to grow over time.

Common Mistake: Mixing Responsibilities

One of the most common mistakes is mixing responsibilities between components.

Typical examples include:

  • Putting business logic in the Controller, making it overly complex and hard to test.
  • Adding business logic or data access directly in the View, with PHP code that pulls data from the database or performs complex calculations.
  • Using the Model for tasks that should be handled by the View or Controller, such as formatting output.

These mistakes can lead to inflexible code that’s hard to scale.

Understanding the Roles of Model, View, and Controller

A proper implementation of the MVC (Model-View-Controller) architecture depends on a clear understanding of each component’s role. This pattern is designed to separate responsibilities and improve code organization, but developers—especially beginners—often end up mixing the roles of Model, View, and Controller, leading to confusion and hard-to-maintain code.

Roles and Best Practices

To avoid these issues, it’s essential to respect the specific roles of each component in the MVC architecture:

The Model: Data Handling and Business Logic

  • The Model is responsible for data access, which can include reading/writing from a database, file, or API.
  • It contains the business logic—meaning the specific rules and behaviors of the application. For example, calculating a discount or validating a transaction.
  • It should be self-contained and not depend on the View or Controller, making it reusable in different contexts.

Here’s an example:

class ProductModel 
{
    public function getProductsByCategory(int $categoryId) 
    {
        return Database::query("SELECT * FROM products WHERE category_id = ?", [$categoryId]);
    }

    public function calculateDiscount(float $price, int $discountPercentage) 
    {
        return $price - ($price * $discountPercentage / 100);
    }
}

The Controller: Application Logic and Coordination

  • The Controller acts as an intermediary between the Model and the View.
  • It receives user input (via URL, forms, or API) and decides which Models to call and which data to pass to the View.
  • It should be as lightweight as possible, delegating logic to the Model.

Here’s an example:

class ProductController 
{
    private $model;

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

    public function showProducts(int $categoryId) 
    {
        $products = $this->model->getProductsByCategory($categoryId);

        return view('views/products.php', $products);
    }
}

The View: Data Presentation

  • The View is solely responsible for presenting data to the user.
  • It should not contain business logic or directly access data, but only display what has been prepared by the Controller.
  • Using template engines (like Twig or Blade) can help keep the View clean and readable.

Here’s an example:

<!-- views/products.php -->
<h1>Prodotti</h1>
<ul>
    <?php foreach ($products as $product): ?>
        <li><?= htmlspecialchars($product['name']) ?> - €<?= number_format($product['price'], 2) ?></li>
    <?php endforeach; ?>
</ul>

Benefits of Respecting Responsibilities

  • Maintainability: Each component is independent, making it easier to identify and fix issues.
  • Reusability: The Model can be reused in different contexts without depending on the View or Controller.
  • Scalability: Separating responsibilities makes it easier to add new features without compromising existing code.

Respecting the roles of Model, View, and Controller is the first step toward building solid, maintainable, and scalable applications.

Where to Write Business Logic

Deciding where to place business logic (in the Model or in a separate service class) depends on the approach and best practices adopted in the project.

When to Use the Model for Business Logic

Traditionally, in MVC architecture, the Model not only handles data access but also takes care of business logic. This approach fits simple to medium-sized applications with tightly coupled logic and domain data. For example:

Calculating the total of an order based on associated products.

Checking that a booking does not exceed availability.

In these cases, embedding the logic directly in the Model can make the code more concise and easier to follow.

When to Use a Service Class

For more complex applications, separating business logic into a service class is often a better choice. Here’s why:

  1. Separation of Concerns
  • The Model focuses exclusively on data and their relationships.
  • Service classes handle business logic, which may involve multiple Models or complex logic not directly tied to a single Model.
  1. Reusability
  • A service class is more easily reusable in different contexts without needing to directly instantiate a Model.
  • Logic can be shared across various components of the application, such as APIs, CLI scripts, or batch processes.
  1. Testability
  • Separating business logic into a service class makes the code easier to test in isolation.
  • Models can be tested for their interaction with the database, while business logic can be verified independently.

Service Class: The Advantages

Using a service class for business logic is generally a more robust and scalable choice, especially for large or complex applications. This approach:

  • Promotes a clear separation of concerns.
  • Enhances code reusability.
  • Increases the testability of individual components.

Keeping Controllers Lightweight: S.O.L.I.D. and Single Action Controller

In the design of an MVC application, one of the most important aspects is keeping controllers lightweight and focused. To achieve this, developers can adopt the principle of Single Action Controllers, along with programming best practices like the S.O.L.I.D. principles. As a result, controllers should be minimized, with each one dedicated to a single responsibility—namely, a single action. This approach, in turn, improves maintainability, testability, and code clarity. However, the downside is that there will be many more smaller classes in the project. For instance, instead of having one controller handling the index, edit, and delete actions for an entity, following the Single Action Controller rule means creating three different classes, each with a single __invoke method.

Common Mistake: “Fat” Controllers

A common mistake is having controllers that contain too much logic, such as:

  • Handling data validation.
  • Processing multiple actions in a single controller.
  • Complex logic that doesn’t directly relate to the coordination between the view and model.

This makes the controller difficult to understand, test, and maintain. If a controller manages too many actions or responsibilities, any change in one part of the code could have unintended effects on other functionalities.

Best Practices: Single Action Controllers

  1. Adopt Single Action Controllers
    Single Action Controllers follow the philosophy that each controller should handle only one action or responsibility. This approach makes the controller highly focused and easy to manage. The basic idea is that each controller should be the coordination point for a specific action, such as displaying an item, saving data, or modifying a resource.

A controller handles only one action and passes the data to the model or a service to execute the business logic. The view then handles the presentation.

Example of a Single Action Controller:

class CreateEntityController 
{
    public function __invoke(Request $request) 
    {
        $validation = $request->validated();

        $entity = EntityService::create($request->all());

        return redirect()->route('entity.show', ['id' => $entity->id]);
    }
}

In this example, the controller focuses only on creating an entity and doesn’t handle other operations like editing or displaying. Each controller can thus be easier to test and modify without introducing bugs in other areas of the application.

  1. Use Services and Models for Business Logic
    To prevent controllers from becoming too complex, you should move business logic (such as user creation, payment processing, etc.) to service classes or models. In this way, these services or models take care of the heavy logic. Meanwhile, controllers stay lightweight, focusing on managing the flow of information and coordinating actions.

Example of using a service class:

class EntityService 
{
    public function create(array $data) 
    {
        $entity = new EntityModel($data);
        $entity->save();

        return $entity;
    }
}

class CreateEntityController 
{
    private $entityService;

    public function __construct(EntityService $entityService) 
    {
        $this->entityService = $entityService;
    }

    public function store(Request $request) 
    {
        $validation = $request->validated();

        $entity = $this->entityService->create($request->all());

        return redirect()->route('entity.show', ['id' => $entity->id]);
    }
}

In this case, the user creation logic is delegated to the EntityService service class, while the controller is responsible only for handling the request and redirecting the user to a new page.

  1. Advantages of Single Action Controllers
    Adopting Single Action Controllers brings several benefits:
  • Testability: Each controller is responsible for a single action, making unit testing easier. The complex logic is external to the controller, so tests focus solely on the behavior of the action.

  • Maintainability: A controller that handles only one action is easy to understand and modify. Adding new features does not mean modifying existing controllers but only creating new ones.

  • Scalability: With Single Action Controllers, it’s easy to extend the application by adding new controllers without compromising the cohesion or clarity of the application.

Best Practices: Applying the S.O.L.I.D. Principles

The S.O.L.I.D. principles help maintain code clarity, modularity, and extensibility. These principles are crucial to ensuring that controllers remain lean and well-structured:

    • S: Single Responsibility Principle (SRP): Each class should have a single responsibility. In the case of controllers, each controller should focus on a single action and delegate everything else to other components like services or models. Just like controllers, services should also have a single responsibility.

    • O: Open/Closed Principle (OCP): Classes should be designed to be extensible without modifying their original code. New functionalities can be added, but existing classes should never be altered. For example, in many cases, you can implement the Strategy Pattern.

    • L: Liskov Substitution Principle (LSP): Derived classes should be replaceable with their base class without altering the behavior of the application.

    • I: Interface Segregation Principle (ISP): Interfaces should be designed in such a way that they don’t force classes to implement unused methods.

    • D: Dependency Inversion Principle (DIP): Classes should depend on abstract interfaces or service classes rather than concrete classes, to facilitate testability and maintenance.

    Conclusion

    Keeping controllers lightweight and following the principles of MVC architecture is not just a matter of style; it’s a necessity for building robust, scalable, and maintainable PHP applications. By adopting Single Action Controllers and delegating business logic to dedicated service classes, you can avoid your controllers becoming a “bottleneck” that collects all responsibilities, compromising code clarity and testability. Similarly, applying the S.O.L.I.D. principles not only improves code quality but also makes the entire system more agile and prepared to tackle future evolution.

    Every choice you make in your project architecture will affect its long-term maintainability. If you learn to keep controllers focused, separate responsibilities, and write testable code from the start, you’re building a solid foundation on which you can easily add new features, fix bugs, and improve the system without the fear of compromising the entire application. Never underestimate the power of well-designed architecture: it’s the key to the success of any software project.

    See you soon with the second part of “Best Practices for MVC Architecture in PHP: Common Mistakes and How to Avoid Them.”

    If you enjoyed this article, if you learned something, or if you think someone could benefit from it, feel free to share it on your favorite social media!

    Thank you.

    How useful was this post?

    Click on a star to rate it!

    Average rating 0 / 5. Vote count: 0

    No votes so far! Be the first to rate this post.