objects namespaces and application architecture
Layered Architecture
Layered architecture is a way to organise an application so each part has a clear job and changes in one area do not spread through the whole codebase.
When a PHP application is small, it is tempting to put everything in one controller or one script: read the request, validate input, run database queries, calculate business rules, send email, and return HTML or JSON. That can work for a short demo, but it becomes painful when the project grows. A bug fix in the payment rules should not require editing the same method that reads $_POST, builds SQL, and renders a response.
Layered architecture separates those responsibilities. The exact names vary by framework and team, but a common shape is:
- presentation layer: receives HTTP input and returns a response
- application layer: coordinates a use case such as registering a user or placing an order
- domain layer: contains the business rules and meaningful objects
- infrastructure layer: talks to databases, mail providers, queues, files, APIs, and framework services
The point is not to create folders for their own sake. The point is to make dependency direction obvious. High-level business decisions should not depend on controller details, SQL strings, or a particular email provider.
Why Layers Help
Layers help when you need to understand where a change belongs.
If a form field is missing, that is usually presentation or request validation. If an order total is wrong, that is domain logic. If the order cannot be saved because the database is unavailable, that is infrastructure. If the user registration process needs to create an account, send an email, and record an audit event, that coordination belongs in the application layer.
Without layers, those concerns are often mixed together:
<?php
declare(strict_types=1);
function register(array $post, PDO $pdo): void
{
if (!isset($post['email']) || !str_contains($post['email'], '@')) {
echo 'Invalid email';
return;
}
$email = strtolower(trim($post['email']));
$passwordHash = password_hash($post['password'] ?? '', PASSWORD_DEFAULT);
$statement = $pdo->prepare('INSERT INTO users (email, password_hash) VALUES (?, ?)');
$statement->execute([$email, $passwordHash]);
mail($email, 'Welcome', 'Thanks for joining.');
echo 'Registered';
}
This code has several jobs at once. It reads request data, validates input, normalises an email address, hashes a password, writes to the database, sends mail, and prints output. Testing the registration rule now means knowing about PDO, mail(), and echo.
Layering gives each responsibility a better home.
Presentation Layer
The presentation layer is the outside edge of the application. In a web app, it is usually a controller, route handler, Livewire component, Symfony controller, Laravel controller, Slim action, or plain PHP script.
Its job is to translate HTTP details into a call the application understands. It may read route parameters, form fields, JSON, uploaded files, authenticated user information, and headers. It should not contain most business rules.
<?php
declare(strict_types=1);
final class RegisterUserController
{
public function __construct(
private RegisterUser $registerUser,
) {
}
/**
* @param array{email?: string, password?: string} $request
*/
public function __invoke(array $request): string
{
$result = $this->registerUser->handle(
email: $request['email'] ?? '',
password: $request['password'] ?? '',
);
return $result->message;
}
}
This controller still handles request-shaped data, but it delegates the use case. That keeps the controller thin and makes the registration behaviour easier to test without HTTP.
Application Layer
The application layer coordinates a complete use case. It answers: "what steps happen when this user action runs?"
For registration, the steps might be:
- validate the email and password
- check whether the account already exists
- hash the password
- save the user
- send a welcome email
- return a result the controller can convert into a response
<?php
declare(strict_types=1);
final readonly class RegistrationResult
{
public function __construct(
public bool $succeeded,
public string $message,
) {
}
}
interface UserRepository
{
public function existsByEmail(EmailAddress $email): bool;
public function save(User $user): void;
}
interface PasswordHasher
{
public function hash(string $plainPassword): string;
}
interface WelcomeMailer
{
public function sendWelcomeEmail(EmailAddress $email): void;
}
final class RegisterUser
{
public function __construct(
private UserRepository $users,
private PasswordHasher $passwords,
private WelcomeMailer $mailer,
) {
}
public function handle(string $email, string $password): RegistrationResult
{
if (strlen($password) < 10) {
return new RegistrationResult(false, 'Password must be at least 10 characters.');
}
$emailAddress = EmailAddress::fromString($email);
if ($this->users->existsByEmail($emailAddress)) {
return new RegistrationResult(false, 'An account already exists for that email.');
}
$user = new User($emailAddress, $this->passwords->hash($password));
$this->users->save($user);
$this->mailer->sendWelcomeEmail($emailAddress);
return new RegistrationResult(true, 'Registered successfully.');
}
}
The application service depends on interfaces, not concrete infrastructure. It knows that users must be saved and email must be sent, but it does not know whether the database is MySQL, PostgreSQL, SQLite, or an external API.
Domain Layer
The domain layer holds the rules and concepts that are meaningful to the business.
In a shop, that might be Order, Money, DiscountCode, Basket, and ShippingMethod. In a learning platform, it might be Course, Lesson, Enrollment, and Progress. In a booking system, it might be Booking, TimeSlot, and CancellationPolicy.
Domain objects should not need to know about HTTP, HTML, sessions, SQL, framework controllers, or environment variables.
<?php
declare(strict_types=1);
final readonly class EmailAddress
{
private function __construct(
public string $value,
) {
}
public static function fromString(string $email): self
{
$email = strtolower(trim($email));
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('Email address is not valid.');
}
return new self($email);
}
}
final readonly class User
{
public function __construct(
public EmailAddress $email,
public string $passwordHash,
) {
if ($passwordHash === '') {
throw new InvalidArgumentException('Password hash cannot be empty.');
}
}
}
These classes are small, but they protect important rules. Once an EmailAddress exists, the rest of the application can trust that it has been trimmed, lowercased, and validated.
Infrastructure Layer
The infrastructure layer contains code that talks to the outside world. It is where concrete database repositories, email senders, file storage, cache clients, HTTP clients, queue publishers, and framework adapters usually live.
<?php
declare(strict_types=1);
final class PdoUserRepository implements UserRepository
{
public function __construct(
private PDO $pdo,
) {
}
public function existsByEmail(EmailAddress $email): bool
{
$statement = $this->pdo->prepare('SELECT COUNT(*) FROM users WHERE email = ?');
$statement->execute([$email->value]);
return (int) $statement->fetchColumn() > 0;
}
public function save(User $user): void
{
$statement = $this->pdo->prepare(
'INSERT INTO users (email, password_hash) VALUES (?, ?)'
);
$statement->execute([$user->email->value, $user->passwordHash]);
}
}
This class is deliberately concrete. It knows about PDO, SQL, table names, and column names. Those details belong here, not inside the domain object or controller.
Dependency Direction
A useful rule of thumb is: inner layers should not depend on outer layers.
The domain layer should not call a controller. The domain layer should not know about PDO. An application service can depend on domain objects and repository interfaces. Infrastructure can implement those interfaces using databases or external systems.
One common dependency direction looks like this:
Presentation -> Application -> Domain
Infrastructure -> Application interfaces / Domain objects
This may feel unusual at first because infrastructure often contains the concrete classes that do the real saving and sending. The trick is that the application defines what it needs through interfaces, and the infrastructure provides implementations.
Folders Are Not Enough
Creating folders named Controller, Service, Domain, and Infrastructure does not automatically create good architecture. The real question is what each class is allowed to know.
This is still poorly layered:
<?php
declare(strict_types=1);
final class Order
{
public function save(PDO $pdo): void
{
$statement = $pdo->prepare('INSERT INTO orders (total) VALUES (?)');
$statement->execute([1000]);
}
}
Order sounds like a domain object, but it knows how to save itself with PDO. That mixes business state with persistence. A better design keeps the order rules in the domain object and uses a repository in infrastructure to save it.
A Practical Review Checklist
When reviewing layered PHP code, ask:
- Does the controller mostly translate HTTP input and output?
- Does the application service coordinate one use case?
- Do domain objects contain business rules without framework or database details?
- Are database, email, file, queue, and API calls kept in infrastructure classes?
- Do dependencies point inward toward the domain rather than outward toward controllers?
- Can the use case be tested without a real web request?
- Can infrastructure be swapped or faked in a test without changing business logic?
Layered architecture should make code easier to change, not harder. If the design creates five classes for a two-line script, it may be too much. If one method is doing request parsing, validation, business rules, SQL, and output, it is not enough.
Common Mistakes
A common mistake is putting every class with the word Service into the application layer. Some services are really infrastructure, such as SmtpMailer, StripePaymentGateway, or FilesystemAvatarStorage. The name matters less than what the class knows and what side effects it performs.
Another mistake is letting framework convenience leak everywhere. Calling request(), auth(), config(), or static facades deep inside domain logic makes the code harder to test and reuse. It also hides dependencies from the constructor, so another developer cannot easily see what the class needs.
Beginners also sometimes over-correct and create too much structure too early. A layered design should match the size and risk of the feature. Use stronger boundaries where the rules matter, the code changes often, or the side effects are expensive.
What You Should Be Able To Do
After this lesson, you should be able to look at a PHP feature and describe which layer each part belongs to. You should be able to keep controllers thin, put use-case coordination in an application service, keep business rules in domain objects, and place database or external service code in infrastructure.
That skill matters in junior roles because you will often be asked to add small features inside existing projects. Knowing where code belongs helps you avoid the most common maintenance problem: putting a quick change in a place that makes the next change harder.
Practice
Practice: Split a User Registration Feature Into Layers
You have been asked to model a small user registration flow. Keep the code framework-free so the layer boundaries are easy to see.
Task
Create a PHP example with these parts:
- a presentation class that receives request-shaped data and calls the use case
- an application service that coordinates registration
- a domain value object for an email address
- a domain entity or data object for a user
- an infrastructure repository that stores users in memory
The registration flow should:
- reject invalid email addresses
- reject passwords shorter than 10 characters
- reject duplicate email addresses
- save a valid user
- return a short message for each result
Use strict types and constructor injection. The application service should depend on a repository interface, not directly on the in-memory repository implementation.
Check Your Work
Run at least these cases:
- a valid registration
- an invalid email address
- a short password
- a duplicate email address
Before you finish, point to which class belongs to each layer and explain why.
Show solution
One good solution is to make the controller handle request-shaped data, the application service coordinate the use case, the domain objects protect business meaning, and the repository implementation handle storage.
<?php
declare(strict_types=1);
final readonly class EmailAddress
{
private function __construct(
public string $value,
) {
}
public static function fromString(string $email): self
{
$email = strtolower(trim($email));
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('Email address is not valid.');
}
return new self($email);
}
}
final readonly class User
{
public function __construct(
public EmailAddress $email,
public string $passwordHash,
) {
if ($passwordHash === '') {
throw new InvalidArgumentException('Password hash cannot be empty.');
}
}
}
interface UserRepository
{
public function existsByEmail(EmailAddress $email): bool;
public function save(User $user): void;
}
final class InMemoryUserRepository implements UserRepository
{
/** @var array<string, User> */
private array $users = [];
public function existsByEmail(EmailAddress $email): bool
{
return array_key_exists($email->value, $this->users);
}
public function save(User $user): void
{
$this->users[$user->email->value] = $user;
}
}
final readonly class RegistrationResult
{
public function __construct(
public bool $succeeded,
public string $message,
) {
}
}
final class RegisterUser
{
public function __construct(
private UserRepository $users,
) {
}
public function handle(string $email, string $password): RegistrationResult
{
if (strlen($password) < 10) {
return new RegistrationResult(false, 'Password must be at least 10 characters.');
}
try {
$emailAddress = EmailAddress::fromString($email);
} catch (InvalidArgumentException $exception) {
return new RegistrationResult(false, $exception->getMessage());
}
if ($this->users->existsByEmail($emailAddress)) {
return new RegistrationResult(false, 'An account already exists for that email.');
}
$user = new User(
email: $emailAddress,
passwordHash: password_hash($password, PASSWORD_DEFAULT),
);
$this->users->save($user);
return new RegistrationResult(true, 'Registered successfully.');
}
}
final class RegisterUserController
{
public function __construct(
private RegisterUser $registerUser,
) {
}
/**
* @param array{email?: string, password?: string} $request
*/
public function __invoke(array $request): string
{
$result = $this->registerUser->handle(
email: $request['email'] ?? '',
password: $request['password'] ?? '',
);
return $result->message;
}
}
$repository = new InMemoryUserRepository();
$controller = new RegisterUserController(new RegisterUser($repository));
echo $controller([
'email' => 'Ada@example.com',
'password' => 'correct horse battery staple',
]) . PHP_EOL;
echo $controller([
'email' => 'not-an-email',
'password' => 'correct horse battery staple',
]) . PHP_EOL;
echo $controller([
'email' => 'grace@example.com',
'password' => 'short',
]) . PHP_EOL;
echo $controller([
'email' => 'ada@example.com',
'password' => 'another good password',
]) . PHP_EOL;
// Prints:
// Registered successfully.
// Email address is not valid.
// Password must be at least 10 characters.
// An account already exists for that email.
The presentation layer is RegisterUserController because it accepts request-shaped data. The application layer is RegisterUser because it coordinates the registration use case. The domain layer is EmailAddress and User because they represent meaningful business concepts. The infrastructure layer is InMemoryUserRepository because it provides storage behind the UserRepository interface.
In a real application, the infrastructure repository would probably use a database instead of an array. The application service would not need to change as long as the database-backed repository still implements UserRepository.