What Separation of Concerns Actually Looks Like in PHP
Most PHP codebases that become difficult to maintain started as files that mixed database logic, business logic, and HTML output in the same function. One file handles a form submission, runs a query, applies some conditional logic, and outputs HTML across two hundred lines. It works. Until someone needs to change something. Then every change becomes a surgical procedure performed in the dark because the boundaries that should make the work safe were never built.
Separation of concerns is not an architectural preference or a luxury reserved for large applications. It is the difference between a codebase that can be maintained and one that cannot. Understanding what separation looks like in practice, and why it matters even in small applications, changes how you write and revise PHP code.
The Three Layers Every PHP Application Has
A PHP application, regardless of size, handles three distinct types of work. The first is data access: connecting to the database, running queries, and returning results. The second is business logic: applying rules, making decisions, transforming data, and coordinating what happens. The third is presentation: taking processed data and outputting it in a format the user can consume, whether that is HTML, JSON, or something else.
Each of these is a separate concern. They are related, but they do different types of work and they change for different reasons. A change to the database schema does not require a change to how data is displayed. A change to business rules does not require a change to how data is stored. A change to the design does not require a change to how queries are written. When these concerns are mixed in the same file, every change risks affecting all three areas simultaneously.
What Happens When Layers Are Mixed
Consider a file that handles a contact form submission. It receives POST data, sanitises it, checks whether the email is already in the database, inserts a new record if it is not, sends a confirmation email, and renders a thank you page. In a mixed codebase, this might all be in a single file with one hundred and fifty lines of PHP and HTML interleaved.
When the business needs to add a phone number field to the form, the developer needs to find every place that handles contact form data and update each one. The insert query needs updating. The email confirmation needs updating. The validation needs updating. The thank you page might need updating. If any of these is missed, the result is inconsistent data, a broken email, or a confusing user experience.
Now consider doing the same change in a codebase where data access is in a model, business logic is in a service, and presentation is in a view. The change to the data access layer is one update to the model. The change to the business logic is one update to the service. The change to the presentation is one update to the view. Each change is isolated to its layer. The risk of missing a place where the concern is mixed is eliminated by the structure itself.
A Practical Directory Structure for Separation
A small PHP application can use a structure with three directories: models for the data access layer, services for the business logic layer, and views for the presentation layer. A fourth directory for controllers handles the request-response cycle, routing data from services to views.
/app
/models
ContactModel.php
UserModel.php
/services
ContactService.php
AuthService.php
/views
contact-form.php
thank-you.php
/controllers
ContactController.php
The controller receives the HTTP request, calls the appropriate service method with the request data, and passes the service response to the appropriate view. The service method contains the business logic. The model contains the database queries. The view contains the HTML with minimal PHP for rendering data.
This is the MVC pattern in its simplest form. The naming is less important than the discipline of keeping each layer separate and not mixing concerns within a single file. A file that contains both database queries and HTML output has already failed the separation test, regardless of what pattern name is applied to it.
The Data Access Layer: Models
The data access layer knows only about database connections, queries, and results. It does not make business decisions. It does not know what the data will be used for. It provides methods that return data in a usable form.
class ContactModel
{
private PDO $db;
public function __construct(PDO $db)
{
$this->db = $db;
}
public function findByEmail(string $email): ?array
{
$stmt = $this->db->prepare(
'SELECT * FROM contacts WHERE email = ?'
);
$stmt->execute([$email]);
$result = $stmt->fetch(PDO::FETCH_ASSOC);
return $result ?: null;
}
public function create(array $data): int
{
$stmt = $this->db->prepare(
'INSERT INTO contacts (name, email, phone, message, created_at)
VALUES (:name, :email, :phone, :message, NOW())'
);
$stmt->execute([
'name' => $data['name'],
'email' => $data['email'],
'phone' => $data['phone'] ?? null,
'message' => $data['message']
]);
return (int) $this->db->lastInsertId();
}
}
The model returns domain-ready data, not raw database results. The controller does not need to know the column names, the table structure, or how the data is indexed. It receives an array or object it can use. This means changing the database schema requires updating only the model, not every file that touches the contacts table.
When building the data access layer, it is worth considering security alongside structure. Parameterised queries protect against SQL injection, which is one of the most common security risks in PHP applications. For a more detailed security checklist, you can review PHP security best practices for business websites.
The Business Logic Layer: Services
The service layer receives data from the model, applies the rules and decisions of the business, coordinates between different models, and returns results ready for presentation. It knows nothing about how data is stored or how results are displayed.
class ContactService
{
private ContactModel $contactModel;
private Mailer $mailer;
public function __construct(ContactModel $contactModel, Mailer $mailer)
{
$this->contactModel = $contactModel;
$this->mailer = $mailer;
}
public function submitContactForm(array $formData): ServiceResult
{
if (empty($formData['email']) || empty($formData['message'])) {
return new ServiceResult(false, 'Email and message are required');
}
$existing = $this->contactModel->findByEmail($formData['email']);
if ($existing) {
return new ServiceResult(false, 'This email has already been submitted');
}
$contactId = $this->contactModel->create($formData);
$this->mailer->sendConfirmation($formData['email'], $contactId);
return new ServiceResult(true, 'Contact form submitted', ['id' => $contactId]);
}
}
The service handles validation, checks for duplicates, and coordinates the model and mailer. If the validation logic needs to change, only this file is updated. If the email sending needs a different approach, only the mailer is changed. The service does not need to change because the interface between components is maintained.
This layered approach also makes it easier to identify where specific application security risks apply. Business logic that is isolated in services is easier to audit and review for issues like broken access control or business rule bypass. Understanding common application security risks helps when designing service layer logic. The OWASP Top 10 guide for business web applications covers the risks worth keeping in mind during this design phase.
The Presentation Layer: Views
The view receives processed data and renders it to a format the user can consume. It knows nothing about business rules or database queries. It should contain only simple rendering logic: loops, conditionals for showing or hiding content, and formatting of data that has already been processed.
<?php $data = $viewData; ?>
<h2>Thank You</h2>
<p>Your message has been received. We will respond to
<?= htmlspecialchars($data['email']) ?> within one business day.</p>
A good view contains no database queries, no business logic conditions beyond simple rendering, and no complex data transformation. If a view is doing more than rendering, the presentation logic and the business logic are not yet properly separated.
Views can also output formats other than HTML. A single service can return data that a controller passes to different views depending on the request. A JSON API view receives the same processed data and outputs it as JSON instead of HTML. This flexibility only works when the service layer has already done the heavy lifting of business logic and data preparation.
The Request-Response Layer: Controllers
Controllers sit between the HTTP layer and the service layer. They receive the incoming request, extract the relevant data, call the appropriate service method, and pass the result to the appropriate view. Controllers should be thin. If a controller contains business logic, that logic belongs in a service.
class ContactController
{
private ContactService $contactService;
private Renderer $renderer;
public function __construct(ContactService $contactService, Renderer $renderer)
{
$this->contactService = $contactService;
$this->renderer = $renderer;
}
public function submit(): void
{
$result = $this->contactService->submitContactForm($_POST);
if ($result->isSuccess()) {
$this->renderer->render('thank-you', $result->getData());
} else {
$this->renderer->render('contact-form', ['error' => $result->getMessage()]);
}
}
}
The controller handles the flow of the application. It decides what happens next based on the result from the service. This keeps the service focused on business logic and the view focused on presentation.
How to Refactor a Mixed Codebase
Refactoring to separate concerns is a methodical process. The first step is to find every place where database queries appear and group them by table. In each group, identify the methods that run queries and move them into a model class dedicated to that table. The model should return domain-ready data, not raw results.
The second step is to identify business logic that is embedded in files that also handle presentation or data access. Business logic is any code that makes decisions, applies rules, or transforms data for use rather than for storage or display. Move this into service classes. A service class receives data from a model, applies the business rules, and returns a result.
The third step is to extract HTML from files that mix it with logic. Move the HTML into template files that receive data from the controller and render it. The template should contain only presentation logic: loops for lists, conditionals for optional content, and formatting of data that has already been processed.
Each refactoring step should be tested before moving to the next. A mixed codebase is fragile. Making changes to the structure without a test suite means each refactoring step risks breaking something that is not immediately visible. If there is no test suite, manual testing of the affected user journeys after each change is the minimum acceptable approach.
Before refactoring: Back up the database and codebase. Create a snapshot or version control branch you can return to if something goes wrong during the refactoring process.
Why the Discipline Matters Even in Small Applications
The separation of concerns feels like overkill for a small application. The argument is that the application is small enough to keep in one file, that the overhead of a structure is not justified by the scale of the project. This is usually a mistake that is discovered later.
Small applications grow. A contact form becomes a CRM. A simple catalogue becomes a full e-commerce platform. An internal tool becomes something the business depends on. When a small application grows and the initial structure was not built to accommodate growth, the refactoring cost is much higher than the cost of building the structure correctly at the start.
Even for applications that genuinely do not need to grow, separation of concerns makes testing possible. A service class that receives model data and returns processed results can be unit tested without a web server, a database, or a browser. A file that mixes all three concerns cannot be tested in isolation.
The Test That Separates Good from Bad Structure
A quick way to test whether concerns are properly separated: can you change the database without touching the business logic? If changing a column name in the database requires updating business rules in multiple files, the separation is not working. Can you change the presentation without touching the business logic? If updating the HTML requires finding every place where data is processed and confirming the format is still correct, the separation is not working.
The goal is that a change to one layer affects only that layer. If the change propagates across layers, the separation is incomplete. The discipline of keeping these concerns apart is what makes a codebase maintainable over time.
When Separation Goes Too Far
Separation of concerns is a principle, not a rule that must be applied absolutely. A service that exists only to wrap a single model method and does nothing else is overhead without benefit. A model that has one method and is used in only one place is a class that does not need to exist as a separate entity.
The goal is practical separation: data access knows about data, business logic knows about rules, presentation knows about output. When this is achieved, the structure is working. When it is achieved in the simplest way that serves the application, the structure is right.
Over-engineering the structure early creates its own maintenance burden. Adding classes, directories, and abstraction layers for an application that will never need them adds complexity without benefit. Start simple. Add structure when the code shows it needs it, not before.
Separation and API Design
When building PHP applications that expose an API, separation of concerns becomes even more important. The same service layer that feeds HTML views can feed JSON responses to API clients. The business logic does not need to know whether the output is a webpage or an API response.
A well-separated codebase makes it straightforward to support multiple output formats from the same core logic. The controller determines the format based on the request, the service handles the business rules, and the view transforms the result. This is a natural extension of the MVC pattern that works equally well for web pages and API endpoints.