0Pricing
PHP Academy · Lesson

Domain Events and Domain Services

Capture business facts with domain events and services.

Domain Events and Domain Services is a free PHP Academy lesson on CoddyKit — lesson 3 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the PHP Academy learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.

Capturing business facts

Not every piece of behavior fits inside a single entity. Domain Events capture meaningful business facts that have already happened (OrderPlaced), letting other parts of the system react. Domain Services hold domain logic that doesn't naturally belong to one entity or value object. This lesson shows how both keep your model expressive without bloating aggregates.

What is a Domain Event

A Domain Event is an immutable record of something that occurred in the domain, named in the past tense using the ubiquitous language: OrderPlaced, PaymentReceived, CustomerRelocated. It carries the data describing the fact plus the time it happened. It is a value object: created once, never changed.

<?php
final class OrderPlaced {
    public function __construct(
        public readonly string $orderId,
        public readonly int $totalCents,
        public readonly DateTimeImmutable $occurredOn = new DateTimeImmutable()
    ) {}
}
$e = new OrderPlaced('o1', 1300);
echo $e->orderId, ' @ ', $e->occurredOn->format('H:i'), PHP_EOL;

Aggregates record events

The aggregate root records events as part of its behavior, then exposes them so the application layer can dispatch them after the transaction commits. The aggregate itself does not dispatch — it only records. This keeps the domain free of infrastructure concerns.

<?php
final class OrderPlaced {
    public function __construct(public readonly string $orderId) {}
}
abstract class AggregateRoot {
    private array $events = [];
    protected function record(object $e): void { $this->events[] = $e; }
    public function releaseEvents(): array {
        $out = $this->events; $this->events = []; return $out;
    }
}
final class Order extends AggregateRoot {
    public function __construct(public readonly string $id) {
        $this->record(new OrderPlaced($id));
    }
}
$o = new Order('o1');
foreach ($o->releaseEvents() as $e) { echo get_class($e), PHP_EOL; }

Dispatch after commit

The application service persists the aggregate, commits, then dispatches the released events. Dispatching after commit ensures handlers never react to a fact that was rolled back. The ordering below is the safe template for side effects like emails or external calls.

<?php
final class PlaceOrderService {
    public function __construct(
        private object $repo,
        private object $dispatcher
    ) {}
    public function handle(object $order): void {
        // 1. persist inside a transaction
        $this->repo->save($order);   // commit happens here
        // 2. only AFTER commit, release and dispatch
        foreach ($order->releaseEvents() as $event) {
            $this->dispatcher->dispatch($event);
        }
    }
}
echo "order persisted, then events dispatched", PHP_EOL;

Handlers react

Each event can have many handlers, decoupled from the aggregate that raised it. A PSR-14 dispatcher routes the event to registered listeners. Adding a new reaction (analytics, notification) means adding a listener, never touching the aggregate.

<?php
final class OrderPlaced { public function __construct(public readonly string $orderId) {} }

final class Dispatcher {
    private array $listeners = [];
    public function on(string $event, callable $l): void { $this->listeners[$event][] = $l; }
    public function dispatch(object $event): void {
        foreach ($this->listeners[$event::class] ?? [] as $l) { $l($event); }
    }
}
$d = new Dispatcher();
$d->on(OrderPlaced::class, fn($e) => print("email for {$e->orderId}\n"));
$d->on(OrderPlaced::class, fn($e) => print("analytics for {$e->orderId}\n"));
$d->dispatch(new OrderPlaced('o1'));

Events across boundaries

Domain events decouple aggregates that must stay consistent without sharing a transaction. OrderPlaced in the Sales context can trigger inventory reservation in another context via an event. Internally they are objects; across services they often become integration events on a message broker. Keep internal domain events and external integration events distinct — the public contract should not be your internal model.

What is a Domain Service

A Domain Service holds domain logic that:

  • involves multiple aggregates or entities, so it fits in none of them, and
  • is a genuine domain concept, expressed in the ubiquitous language.

It is stateless and operates on domain objects. A FundsTransfer service that debits one account and credits another is the classic example.

A Domain Service in code

The service coordinates two aggregates but holds no state of its own. The logic belongs to neither account, so it lives in a service named for the domain operation.

<?php
final class Account {
    public function __construct(public readonly string $id, private int $cents) {}
    public function withdraw(int $c): void {
        if ($c > $this->cents) { throw new DomainException('insufficient funds'); }
        $this->cents -= $c;
    }
    public function deposit(int $c): void { $this->cents += $c; }
    public function balance(): int { return $this->cents; }
}
final class MoneyTransferService {
    public function transfer(Account $from, Account $to, int $cents): void {
        $from->withdraw($cents);
        $to->deposit($cents);
    }
}
$a = new Account('a', 1000); $b = new Account('b', 0);
(new MoneyTransferService())->transfer($a, $b, 400);
echo $a->balance(), '/', $b->balance(), PHP_EOL; // 600/400

Domain vs Application service

Don't confuse the two:

  • A Domain Service contains business logic and lives in the domain layer; it knows nothing about transactions or HTTP.
  • An Application Service orchestrates a use case: it loads aggregates from repositories, calls domain logic, commits the transaction, and dispatches events. It is a thin coordinator with no business rules of its own.

Avoid the service trap

Domain services are easy to overuse. If logic can live on an entity or value object, it should: that is how you avoid an anemic model. Reach for a domain service only when the operation truly spans multiple aggregates or has no natural home. Otherwise you'll drain behavior out of your entities and recreate procedural code.

Application service coordinates

Putting it together: a thin application service loads aggregates from repositories, invokes a domain service for cross-aggregate logic, persists the result, and dispatches events. It contains orchestration only — no business rules of its own.

<?php
final class Account {
    public function __construct(public readonly string $id, public int $cents) {}
}
final class MoneyTransferService { // domain service: the rule
    public function transfer(Account $from, Account $to, int $c): void {
        if ($c > $from->cents) { throw new DomainException('insufficient'); }
        $from->cents -= $c; $to->cents += $c;
    }
}
final class TransferApp { // application service: orchestration only
    public function __construct(private MoneyTransferService $domain) {}
    public function run(Account $a, Account $b, int $c): void {
        $this->domain->transfer($a, $b, $c); // then repo->save + dispatch
    }
}
$a = new Account('a', 1000); $b = new Account('b', 0);
(new TransferApp(new MoneyTransferService()))->run($a, $b, 250);
echo $a->cents, '/', $b->cents, PHP_EOL; // 750/250

Quick Check

Events and services.

Recap

You captured business facts and homeless logic. Domain Events are immutable, past-tense records that aggregates record and the application layer dispatches after commit, decoupling reactions and crossing aggregate boundaries. Domain Services hold stateless domain logic spanning multiple aggregates, distinct from thin application services that merely orchestrate use cases. Use both sparingly so behavior stays on entities and value objects wherever it belongs.

Frequently asked questions

Is the “Domain Events and Domain Services” lesson free?

Yes — the full text of “Domain Events and Domain Services” is free to read here on the web, and the PHP Academy course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the PHP Academy course, upgrade to CoddyKit PRO.

What will I learn in “Domain Events and Domain Services”?

Capture business facts with domain events and services. You practise PHP Academy with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.

Do I need any experience to start PHP Academy?

No prior experience is required. PHP Academy on CoddyKit is structured for beginners through advanced learners; this is — lesson 3 of 4, so you can start here or from the beginning and move at your own pace.

How long does the “Domain Events and Domain Services” lesson take?

Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.

Can I write and run code in this PHP Academy lesson?

Yes. Every PHP Academy lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.

All lessons in this course

  1. DDD Building Blocks: Entities and Value Objects
  2. Aggregates, Repositories and Factories
  3. Domain Events and Domain Services
  4. Bounded Contexts and Context Mapping
← Back to PHP Academy