Application Architecture for PHP Applications

A small script can keep everything in one file. A growing application needs boundaries so routing, database access, business rules, and presentation do not become one untestable block.

A Practical Set of Boundaries

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.

Example Request Flow

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 Maintainable Plain-PHP Structure

app/
├── public/
│   └── index.php
├── src/
│   ├── Application/
│   ├── Domain/
│   ├── Http/
│   └── Infrastructure/
├── templates/
├── config/
├── migrations/
├── tests/
├── storage/
│   └── logs/
├── composer.json
└── vendor/

Only public/ should be exposed by the web server. Configuration, source files, logs, and dependencies should not be directly downloadable.

Services and Repositories

A service expresses a use case. A repository hides persistence details. This does not mean every table needs a class or every function needs an interface. Add structure where it reduces duplication or makes a rule testable.

<?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);
    }
}

Put Transactions Around Business Operations

A transaction should normally cover one complete business operation, not an entire web request. Creating an order and its line items belongs in one transaction. Sending email usually does not; queue it or send it after commit so a temporary mail failure does not corrupt database state.

Start With a Modular Monolith

Most small-business applications do not need microservices. A well-structured monolith is easier to deploy, monitor, back up, and debug. Keep modules internally separated, use explicit interfaces where external systems are involved, and split services only when there is a clear operational reason.

Architecture test: Can you change the database implementation, render a second interface, or test a business rule without rewriting the entire feature? If not, the boundaries may be too tightly coupled.