Skip to main content

Building a Domain-Driven Design Architecture with BowPHP

Β· 6 min read
Franck DAKIA
Principal maintainer

BowPHP ships with a clean MVC layout, but nothing stops you from organising a larger application around Domain-Driven Design (DDD). The framework already gives you the three pieces DDD leans on: a container to bind interfaces to implementations, CQRS to model use-cases, and an event system to publish domain events. In this post we'll wire them together into a layered, testable architecture.

We'll model a single bounded context β€” Ordering β€” and keep the domain at the centre, framework details at the edges.

The layers​

DDD splits a context into layers that depend inward: outer layers know about inner ones, never the reverse.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Presentation Controllers, routes β”‚
β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚ β”‚ Application Commands / Queries β”‚ β”‚
β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚
β”‚ β”‚ β”‚ Domain Entities, VOs, β”‚ β”‚ β”‚
β”‚ β”‚ β”‚ Repository contracts β”‚ β”‚ β”‚
β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚
β”‚ β”‚ Infrastructure Barry repositories β”‚ β”‚
β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Folder structure​

A BowPHP app autoloads App\ from app/ (PSR-4), so a bounded context is just a namespaced folder β€” no composer changes required:

app/
└── Ordering/
β”œβ”€β”€ Domain/
β”‚ β”œβ”€β”€ Order.php # Entity
β”‚ β”œβ”€β”€ Money.php # Value object
β”‚ β”œβ”€β”€ OrderRepository.php # Contract (interface)
β”‚ └── OrderWasPlaced.php # Domain event
β”œβ”€β”€ Application/
β”‚ β”œβ”€β”€ PlaceOrderCommand.php
β”‚ └── PlaceOrderHandler.php
β”œβ”€β”€ Infrastructure/
β”‚ β”œβ”€β”€ Models/OrderModel.php # Barry persistence model
β”‚ └── BarryOrderRepository.php # Contract implementation
└── Presentation/
└── OrderController.php

1. The Domain layer (no framework imports)​

The domain is pure PHP. It doesn't know BowPHP exists β€” that's what keeps it testable and stable.

A small value object:

app/Ordering/Domain/Money.php
namespace App\Ordering\Domain;

final class Money
{
public function __construct(
public readonly int $amountInCents,
public readonly string $currency = 'USD',
) {
if ($amountInCents < 0) {
throw new \InvalidArgumentException('Amount cannot be negative.');
}
}

public function add(Money $other): self
{
return new self($this->amountInCents + $other->amountInCents, $this->currency);
}
}

The entity, with its invariants enforced in the constructor:

app/Ordering/Domain/Order.php
namespace App\Ordering\Domain;

final class Order
{
/** @param list<array{sku: string, price: Money}> $lines */
public function __construct(
public readonly string $id,
public readonly int $customerId,
public readonly array $lines,
) {
if ($lines === []) {
throw new \DomainException('An order needs at least one line.');
}
}

public function total(): Money
{
return array_reduce(
$this->lines,
fn (Money $carry, array $line) => $carry->add($line['price']),
new Money(0),
);
}
}

And the repository contract β€” an interface owned by the domain, implemented later by infrastructure:

app/Ordering/Domain/OrderRepository.php
namespace App\Ordering\Domain;

interface OrderRepository
{
public function nextIdentity(): string;

public function save(Order $order): void;

public function ofId(string $id): ?Order;
}

2. The Application layer (use-cases via CQRS)​

Each use-case is a command plus a handler, using the bowphp/cqrs package. The handler orchestrates the domain; it contains no business rules of its own.

composer require bowphp/cqrs
app/Ordering/Application/PlaceOrderCommand.php
namespace App\Ordering\Application;

use Bow\CQRS\Command\CommandInterface;

final class PlaceOrderCommand implements CommandInterface
{
public function __construct(
public readonly int $customerId,
public readonly array $lines,
) {}
}
app/Ordering/Application/PlaceOrderHandler.php
namespace App\Ordering\Application;

use App\Ordering\Domain\Money;
use App\Ordering\Domain\Order;
use App\Ordering\Domain\OrderRepository;
use App\Ordering\Domain\OrderWasPlaced;
use Bow\CQRS\Command\CommandHandlerInterface;
use Bow\CQRS\Command\CommandInterface;

final class PlaceOrderHandler implements CommandHandlerInterface
{
// The handler depends on the CONTRACT, not on Barry.
public function __construct(private OrderRepository $orders) {}

public function process(CommandInterface $command): mixed
{
$lines = array_map(fn (array $l) => [
'sku' => $l['sku'],
'price' => new Money($l['price']),
], $command->lines);

$order = new Order(
id: $this->orders->nextIdentity(),
customerId: $command->customerId,
lines: $lines,
);

$this->orders->save($order);

// Announce the domain event so other contexts can react.
OrderWasPlaced::dispatch($order->id, $order->total()->amountInCents);

return $order->id;
}
}

The domain event uses BowPHP's dispatchable contract β€” the only framework touch-point in the domain folder, and an intentional one:

app/Ordering/Domain/OrderWasPlaced.php
namespace App\Ordering\Domain;

use Bow\Event\Contracts\AppEvent;
use Bow\Event\Dispatchable;

final class OrderWasPlaced implements AppEvent
{
use Dispatchable;

public function __construct(
public readonly string $orderId,
public readonly int $totalInCents,
) {}

public function getName(): string
{
return self::class;
}
}

3. The Infrastructure layer (Barry behind the contract)​

This is where the framework finally appears. A Barry model handles persistence, and a repository adapter maps between rows and domain entities:

app/Ordering/Infrastructure/Models/OrderModel.php
namespace App\Ordering\Infrastructure\Models;

use Bow\Database\Barry\Model;

class OrderModel extends Model
{
protected string $table = 'orders';
}
app/Ordering/Infrastructure/BarryOrderRepository.php
namespace App\Ordering\Infrastructure;

use App\Ordering\Domain\Money;
use App\Ordering\Domain\Order;
use App\Ordering\Domain\OrderRepository;
use App\Ordering\Infrastructure\Models\OrderModel;

final class BarryOrderRepository implements OrderRepository
{
public function nextIdentity(): string
{
return bin2hex(random_bytes(16));
}

public function save(Order $order): void
{
OrderModel::create([
'id' => $order->id,
'customer_id' => $order->customerId,
'lines' => json_encode($order->lines),
'total' => $order->total()->amountInCents,
])->persist();
}

public function ofId(string $id): ?Order
{
$row = OrderModel::retrieve($id);

if ($row === null) {
return null;
}

$lines = array_map(fn (array $l) => [
'sku' => $l['sku'],
'price' => new Money($l['price']),
], json_decode($row->lines, true));

return new Order($row->id, (int) $row->customer_id, $lines);
}
}

4. Binding the contract to the implementation​

The whole point of the contract is that the application layer never names BarryOrderRepository. We connect the two in a configuration provider, using the container:

app/Configurations/OrderingServiceProvider.php
namespace App\Configurations;

use App\Ordering\Application\PlaceOrderHandler;
use App\Ordering\Domain\OrderRepository;
use App\Ordering\Infrastructure\BarryOrderRepository;
use Bow\Configuration\Configuration;
use Bow\Configuration\Loader;
use Bow\CQRS\Registration as CQRSRegistration;

class OrderingServiceProvider extends Configuration
{
public function create(Loader $config): void
{
// Swap implementations here without touching the domain or application.
$this->container->bind(
OrderRepository::class,
BarryOrderRepository::class,
);
}

public function run(): void
{
CQRSRegistration::handlers([
PlaceOrderHandler::class,
]);
}
}

Register the provider in the kernel:

app/Kernel.php
public function configurations(): array
{
return [
// ...other providers
\App\Configurations\OrderingServiceProvider::class,
];
}

5. The Presentation layer (thin controllers)​

The controller does almost nothing: translate HTTP into a command, hand it to the bus, translate the result back into HTTP.

app/Ordering/Presentation/OrderController.php
namespace App\Ordering\Presentation;

use App\Ordering\Application\PlaceOrderCommand;
use Bow\CQRS\Command\CommandBus;
use Bow\Http\Request;

class OrderController
{
public function __construct(private CommandBus $commandBus) {}

public function store(Request $request)
{
$command = new PlaceOrderCommand(
customerId: (int) $request->get('customer_id'),
lines: $request->get('lines', []),
);

$orderId = $this->commandBus->execute($command);

return response()->json(['order_id' => $orderId], 201);
}
}
routes/app.php
// Use the fully-qualified class name: the router only prepends the default
// App\Controllers namespace when the class string isn't already a real class.
$app->post('/orders', 'App\\Ordering\\Presentation\\OrderController::store');

Why bother?​

  • The domain is pure and testable β€” Order, Money, and the use-case handlers can be unit-tested without a database or HTTP.
  • Dependencies point inward β€” the application layer depends on OrderRepository, never on Barry. Swap the implementation (in-memory for tests, a different store in production) by changing one bind() call.
  • Use-cases are explicit β€” every business action is a named command with a single handler, not logic scattered across fat controllers.
  • Contexts stay isolated β€” OrderWasPlaced lets other parts of the system (or, with microservices, other services) react without the Ordering context knowing they exist.

DDD isn't free β€” it's more files and more indirection, and a simple CRUD app rarely needs it. But when the business rules get complex, this structure keeps them in one obvious place. BowPHP's container, CQRS, and event system give you everything you need to build it β€” no extra framework required.

Is something missing?

If you run into problems with the documentation or have suggestions to improve the documentation or the project in general, please open an issue for us, or send a tweet mentioning the Twitter account @bowframework or directly on github.