Design Patterns And Data Architecture

MVC For PHP Applications

PHP developers meet MVC in different forms. Laravel, Symfony, CodeIgniter, Yii, Slim applications with templates, and many custom projects all use some version of the idea. The exact class names vary, but the pressure is the same: if one file validates request input, runs SQL, decides business policy, builds HTML, sends email, and chooses redirects, it becomes hard to test and change. MVC gives the team a vocabulary for pulling that work apart.

MVC does not guarantee good architecture by itself. A controller can still become enormous. A model can become an active-record object with too many responsibilities. A view can contain business rules hidden in template conditionals. The pattern is useful only when each part owns a clear job.

The Three Responsibilities

A controller is an adapter for an incoming request. It reads route parameters, query parameters, form fields, JSON body data, cookies, session state, and authenticated user context as allowed by the framework. It validates or delegates validation, calls application behavior, then returns an HTTP response such as HTML, JSON, redirect, file download, or error page.

A model is the application's representation of useful state and rules. In many PHP frameworks, model means an ORM record such as an Eloquent model. In broader design, model can mean an entity, value object, domain service, query result, form object, or read model. Be precise in your project. If your team says model and means database row, say that. If your team says model and means domain object, say that.

A view is responsible for presentation. In a server-rendered PHP application, it might be a Twig, Blade, or plain PHP template. In an API, the equivalent presentation layer may be a JSON resource, serializer, normalizer, or response transformer. A view should not decide whether a user is allowed to cancel an order. It may decide how to display an already calculated cancellation state.

The pattern works when data flows in one understandable direction: request enters the controller, application behavior produces a result, and presentation code renders that result.

A Small Boundary Example

This simplified example is not a framework. It shows why moving presentation out of controller logic matters.

PHP example
<?php

declare(strict_types=1);

final readonly class Product
{
    public function __construct(
        public string $name,
        public int $pricePence,
    ) {
    }
}

function renderProduct(Product $product): string
{
    $price = number_format($product->pricePence / 100, 2);

    return '<h1>' . htmlspecialchars($product->name, ENT_QUOTES, 'UTF-8') . '</h1>'
        . '<p>Price: £' . $price . '</p>';
}

$product = new Product('Notebook', 1299);

echo renderProduct($product) . PHP_EOL;

// Prints:
// <h1>Notebook</h1><p>Price: £12.99</p>

The Product object carries state. The rendering function turns that state into HTML and escapes the product name for the output context. In a real MVC application, a controller would load or receive the Product, then pass it to a view or template. The controller should not concatenate every HTML string itself.

Notice that escaping belongs near output. Validation at input and escaping at output solve different problems. A product name can be valid data and still need HTML escaping before it is rendered.

Controllers Should Stay Thin, But Not Empty

"Thin controller" is a useful phrase when it means the controller does not contain database details, long business workflows, or presentation templates. It is not useful when it means the controller hides all decisions behind one vague service call that nobody can inspect.

A good controller usually owns HTTP-specific decisions:

  • which route parameter is required
  • which request body format is accepted
  • which validation errors become 400, 422, redirects, or form messages
  • which application use case is called
  • which response type is returned
  • which status code, headers, and redirect target are appropriate

The controller should delegate business decisions. For example, "can this subscription be cancelled after renewal?" belongs in an application service, domain object, policy, or use-case class. "Should a successful browser form submission redirect to /subscriptions/123?" belongs at the web boundary.

This separation lets you test cancellation rules without building fake HTTP requests, and test controller behavior without reproducing the whole domain.

Models Are Not Always One Thing

Many PHP tutorials say "the model talks to the database." That is sometimes accurate, especially in Active Record frameworks where a model object represents one database-backed record. Larger systems often need more precise names.

An entity represents a business concept with identity, such as Order, User, or Subscription. A value object represents a value without identity, such as Money, EmailAddress, or DateRange. A repository loads and saves objects or read models. A query object may fetch data optimized for one page. A data transfer object may carry validated input from a controller to a use case.

All of these can sit in the broad model layer, but they do different work. Calling all of them "models" can hide responsibility mistakes. If an ORM model sends emails from a save hook, builds HTML labels, validates HTTP form field names, and calculates renewal rules, it is no longer a clear model. It is a mixed object with persistence, presentation, request, and business concerns tangled together.

Use the framework convention, but make the responsibility visible. In Laravel, an Eloquent model can be a practical Active Record. That does not mean every business rule must live inside it. In Symfony, Doctrine entities, services, forms, and Twig templates divide the work differently. The MVC idea should adapt to the framework instead of fighting it.

Views Should Render Decisions, Not Own Them

A template needs conditionals. It may show a cancel button only when the controller or view model says cancellation is available. That is presentation logic. The dangerous version is a template that repeats the actual authorization or business rule: checking roles, dates, payment state, and renewal windows directly in markup.

When business rules live in views, they are hard to test and easy to duplicate. One page hides a button while another page still exposes the action. A JSON endpoint returns a state that disagrees with the HTML page. A later redesign accidentally changes policy because a template condition looked like display code.

Prefer passing prepared values to views: canCancel, formattedPrice, statusLabel, errorMessages, and actionUrl. The view still decides markup, layout, and output escaping. The application decides whether the action is actually allowed.

For APIs, response transformers and serializers have the same risk. They should shape output, not secretly decide business policy.

MVC And Front Controllers

Most modern PHP web applications use a front controller. A front controller is one public entry file, commonly public/index.php, that receives web requests and hands them to a router or framework kernel. The router chooses the controller for the matched route.

MVC and front controller are related but not the same. MVC describes how responsibilities are separated after a request reaches the application. A front controller describes how requests enter the application. You can have MVC with a front controller, and most PHP frameworks do. You can also have a messy front-controller application where one controller file does everything.

The practical review question is: after the front controller routes the request, does the selected controller delegate model and presentation responsibilities clearly?

MVC In Server-Rendered Pages And APIs

In a server-rendered application, the controller often returns a rendered template. The view is visible: a Blade, Twig, or PHP file. The model layer may include ORM models, repositories, form objects, and use-case services.

In a JSON API, the view may be less obvious. The controller might return an array that the framework converts to JSON. That array is still presentation. It decides field names, nesting, omitted values, links, status codes, and error shape. Treat JSON resources, serializers, normalizers, and response factories as the API's view layer.

This matters because API output is a contract. Accidentally returning ORM objects directly can leak internal fields, lazy-load relationships, create inconsistent date formats, and make future changes harder. A response model gives the API boundary a deliberate shape.

Common Failure Modes

One failure mode is the huge controller. It starts small, then gains validation, SQL, payment calls, email, logging, template data preparation, and error handling. The fix is not to move everything into OrderService. The fix is to identify separate responsibilities: request validation, use-case orchestration, persistence, domain rules, external side effects, and response rendering.

Another failure mode is the anemic pass-through controller paired with a vague service class. This improves file length but not design. If UserService::process() receives the whole request and returns mixed arrays for every page, the HTTP boundary has only moved sideways.

A third failure mode is business logic in templates. This often appears as repeated conditions: one view checks isAdmin, another checks role === 'manager', and another checks a date range. Move the decision behind a policy, use case, or prepared view model.

A fourth failure mode is treating ORM models as safe API responses. Database structure and public contract are not the same thing. Public responses should be shaped intentionally.

Testing MVC Boundaries

Test controllers at the HTTP boundary: route, method, request data, authentication state, status code, headers, redirects, session messages, and response body. These tests prove the web adapter is wired correctly.

Test model and use-case behavior without HTTP when possible. A cancellation rule should be testable with objects and a fake repository. It should not require rendering a page just to learn that a renewal window is closed.

Test views or response transformers for output shape and escaping. For HTML, include a value that needs escaping and verify the rendered output is safe. For JSON, verify field names, missing sensitive fields, date formats, null handling, and error envelopes.

The goal is not to test the same behavior three times. The goal is to put each assertion at the boundary that owns it.

When MVC Is Enough

MVC is enough for many PHP applications. A small application with clear controllers, ORM models, templates, and a few focused services can be maintainable for years. Do not add command buses, hexagonal architecture, event sourcing, or microservices just because the word MVC feels basic.

Reconsider the structure when controllers repeatedly grow, model objects mix unrelated responsibilities, templates duplicate policy, or several entry points need the same use case. At that point, patterns such as application services, actions, command handlers, repositories, and presenters may clarify the design.

MVC is a starting vocabulary. It should make the next responsibility split easier, not prevent it.

What To Check

Before moving on, make sure you can:

  • explain controller, model, and view responsibilities in PHP terms
  • distinguish framework-specific model classes from the broader model layer
  • keep HTTP decisions in controllers and business decisions out of templates
  • explain how JSON resources or serializers act as API views
  • describe how MVC relates to a front controller
  • identify huge-controller, vague-service, template-policy, and ORM-leak failures
  • choose useful tests for controllers, models or use cases, and views

After this lesson, you should be able to review a PHP feature and say which code handles the request boundary, which code owns application state and rules, and which code renders the response. That clarity is the reason MVC remains useful even when a project later adopts more specific patterns.

Practice

Task: Separate Controller, Model, And View Responsibilities
  • reads $_POST['email']
  • checks whether the email is valid
  • runs a SQL query to find a user
  • decides whether the user can log in
  • concatenates an HTML response string
  • escapes the email address before output

Separate those responsibilities into controller, model or use-case, repository, and view responsibilities.

Requirements

For each bullet, name the responsibility owner and explain why it belongs there. Mention where output escaping belongs and why validation is not the same as escaping.

Check your work

The answer should not move everything into one vague service. It should preserve clear boundaries.

Show solution

Reading $_POST['email'] belongs at the controller or request-object boundary because it is HTTP input. The controller may delegate validation, but it owns the fact that this value came from a form field.

Email validation can be a request validator, form object, or use-case input factory. It should produce a clear application input or a validation error. Validation decides whether the submitted value is acceptable data; it does not make the value safe for every output context.

The SQL query belongs in a repository or persistence adapter. The controller should ask for a user by a meaningful application query, such as findActiveByEmail(), rather than embedding SQL.

The login decision belongs in a use case, domain service, policy, or model method depending on the project style. It is business behavior, not HTML rendering.

The HTML string belongs in a view or template. Output escaping belongs near the view because it depends on the output context. A valid email address can still contain characters that must be escaped in HTML. If the same value is later written to JSON, a URL, or a shell command, the escaping rules are different.

A reasonable flow is: controller reads request input, validation creates safe application input, use case asks repository for the user and decides the result, controller chooses the response, and the view renders escaped HTML.

Task: Review MVC Boundaries

OrderController::cancel() loads an Eloquent order, checks the current user's role, compares renewal dates, sends a cancellation email, saves the order, builds a Blade view array, and catches all exceptions by returning the same generic error page.

Identify the MVC boundary problems and propose a clearer design.

Requirements

Cover controller size, business rules, persistence, side effects, view data, and error handling.

Check your work

The answer should keep HTTP-specific choices in the controller while moving reusable policy and orchestration out of it.

Show solution

The controller is doing too much. Loading the order may be delegated to a repository or framework route binding, but the cancellation policy should not be embedded directly in the controller. Role checks and renewal-date rules belong in a policy, domain object, or cancellation use case so the same rule can be tested and reused outside the HTML route.

Sending the cancellation email is a side effect. It can be coordinated by the cancellation use case or an application service after the order state changes successfully. The controller should not know mailer payload details.

Saving the order belongs to the persistence boundary used by the application service or repository. If cancellation and side effects need a transaction or outbox, that orchestration should be explicit in the use case rather than hidden inside the controller.

Building view data is acceptable at the controller edge when it is simple, but calculated business states should be prepared by the use case or a presenter. The Blade template should receive clear values such as statusLabel, canRetry, and message, not duplicate cancellation policy.

Catching all exceptions and returning the same error page hides important differences. Validation failure, not found, forbidden, conflict, temporary mail failure, and internal defects deserve different handling. The controller should translate known application outcomes into suitable HTTP responses and let unexpected defects be logged and handled by the framework.

Task: Plan An MVC Refactor

A legacy PHP page mixes SQL, authorization, HTML, and email sending in one file. The team cannot rewrite the whole application at once.

Plan a small MVC-style refactor for one feature: updating a user's profile details.

Requirements

Include:

  • the first boundary you would extract
  • how the controller should receive and validate input
  • where persistence should move
  • how the view should receive display data
  • how you would test the refactor without changing every feature

Check your work

The plan should be incremental and safe. It should not require a full framework migration before improving the feature.

Show solution

Start by extracting the profile update use case from the page. Keep the route and visible behavior the same, but move the decision about which profile fields can change into a focused class such as UpdateProfile. This gives the feature a testable boundary before the whole application is reorganized.

The controller or legacy page adapter should read request fields, pass them through a validator or input object, and call the use case with explicit values. It should not pass the entire $_POST array deeper into the application.

Persistence should move behind a repository or data access function with meaningful methods, such as findUserForProfileUpdate() and saveProfile(). The use case should not concatenate SQL strings, and the view should not know how the profile was stored.

The view should receive a prepared result: field values to show, validation messages, success message, and safe action URLs. It should escape output for HTML and avoid repeating authorization or update rules.

Test the use case with fake persistence for success, validation rejection, and unauthorized update. Add one HTTP or page-level test that submits the form and checks the visible response. This improves one feature without forcing an immediate framework migration.