Application Architecture for PHP Applications
A small script can live happily in one file. I've written plenty of those. But once an application starts growing, it needs some boundaries, or routing, database access, business rules, and HTML all melt together into one block nobody wants to touch. This is the structure I keep coming back to for plain PHP apps.
On this page
The boundaries I use
HTTP layer
Routes requests, reads input, performs request-level validation, and returns HTML or JSON.
Application layer
Coordinates a use case such as creating a booking, closing a ticket, or recording a payment.
Domain rules
Contains the rules that define what is allowed, independent of HTTP and database details.
Infrastructure
Talks to MySQL, mail servers, filesystems, queues, and external APIs.
What a request looks like
POST /customers/42/contacts
│
▼
CustomerContactController
│ validates HTTP input
▼
AddCustomerContact service
│ applies permissions and business rules
▼
CustomerRepository + ContactRepository
│ executes SQL inside a transaction
▼
Redirect to /customers/42
A plain-PHP layout that ages well
app/ ├── public/ │ └── index.php ├── src/ │ ├── Application/ │ ├── Domain/ │ ├── Http/ │ └── Infrastructure/ ├── templates/ ├── config/ ├── migrations/ ├── tests/ ├── storage/ │ └── logs/ ├── composer.json └── vendor/
The important part: only public/ is the web root. Config, source, logs, and vendor/ sit outside it, so nobody can download your database password because a server config got changed.
Services and repositories
A service is one thing the app does ("suspend a customer"). A repository hides how data gets saved and loaded. That doesn't mean every table needs a class or every function needs an interface. I only add structure where it cuts down on duplication or makes a rule testable. Otherwise it's just ceremony.
<?php
declare(strict_types=1);
final class SuspendCustomer
{
public function __construct(
private CustomerRepository $customers,
private AuditLog $auditLog
) {
}
public function execute(int $customerId, int $actorId, string $reason): void
{
$customer = $this->customers->getById($customerId);
if ($customer->isSuspended()) {
return;
}
$customer->suspend($reason);
$this->customers->save($customer);
$this->auditLog->record($actorId, 'customer.suspended', $customerId);
}
}
Wrap transactions around business operations
A transaction should usually cover one complete business operation, not the whole web request. Creating an order and its line items? One transaction. Sending the confirmation email? Not inside it. Queue the email or send it after the commit, so a hiccup with the mail server doesn't leave your database half-updated.
Start with a modular monolith
Most small-business apps don't need microservices, and I'll happily die on that hill. A well-organized monolith is easier to deploy, monitor, back up, and debug. Keep the modules separated inside it, use clear interfaces where outside systems are involved, and only split things out when there's a real operational reason.
