Codeskill

Learn to code, step by step

Domain-oriented structure without cargo-cult DDD

Organising PHP around your business rules – not copying Domain-Driven Design buzzwords because someone on a podcast said you should. The goal is code you can find, test, and change without spelunking through controllers.

Start with the problem, not the pattern

DDD books talk about aggregates, bounded contexts, and ubiquitous language. Useful ideas – when your team and problem are big enough. For a notes app or a small shop, a User class and a NoteRepository beat inventing seven layers of indirection.

Ask: what are the nouns in this app? Users, orders, invoices, submissions. Those become your domain objects or services. HTTP, sessions, and SQL stay at the edges.

Layers that earn their keep

  • Domain – rules that would still be true if you swapped MySQL for CSV files
  • Application – use cases: “register user”, “approve submission” (orchestration)
  • Infrastructure – PDO, mailers, file storage, HTTP clients
  • Delivery – controllers, CLI commands, WordPress hooks

You do not need a folder named Domain/ on day one. You do need a place where “can this user delete that note?” lives – and it should not be copy-pasted across three controllers.

A small domain object

<?php
declare(strict_types=1);

namespace AppDomain;

final class Note
{
    public function __construct(
        public readonly int $id,
        public readonly int $ownerId,
        public string $title,
        public string $body,
    ) {}

    public function rename(string $title): void
    {
        $title = trim($title);
        if ($title === '') {
            throw new InvalidArgumentException('Title cannot be empty.');
        }
        $this->title = $title;
    }
}

Validation that belongs to the thing itself stays on the thing. “Title cannot be empty” is a note rule, not a controller rule.

Repositories without the cult

A repository hides how you load and save. Your domain code asks for a Note; the repository talks to PDO. Interface in application code, implementation in infrastructure:

interface NoteRepository
{
    public function findById(int $id): ?Note;
    public function save(Note $note): void;
}

final class PdoNoteRepository implements NoteRepository
{
    public function __construct(private PDO $pdo) {}

    public function findById(int $id): ?Note
    {
        $stmt = $this->pdo->prepare('SELECT * FROM notes WHERE id = ?');
        $stmt->execute([$id]);
        $row = $stmt->fetch(PDO::FETCH_ASSOC);
        return $row ? $this->map($row) : null;
    }
}

Services for cross-cutting use cases

final class DeleteNote
{
    public function __construct(
        private NoteRepository $notes,
        private Authorizer $auth,
    ) {}

    public function handle(int $noteId, int $actorId, string $role): void
    {
        $note = $this->notes->findById($noteId)
            ?? throw new NotFoundException('Note not found.');

        if (!$this->auth->canDelete($role, $note, $actorId)) {
            throw new ForbiddenException('Not allowed.');
        }

        $this->notes->delete($noteId);
    }
}

The controller becomes thin: parse input, call the use case, map exceptions to HTTP status codes. That is the whole point.

Smells to fix early

  • SQL strings inside controllers
  • Arrays passed everywhere with magic keys like user_role
  • God classes named Utils or Helper
  • Interfaces with one implementation “for testing” that never get tested

Refactor when duplication hurts, not when a diagram looks prettier. Structure is there so the next change is boring – that is success.

PreviousGoing deep with PHP