Building a Domain-Driven Design Architecture with BowPHP
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:
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:
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:
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
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,
) {}
}
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:
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:
namespace App\Ordering\Infrastructure\Models;
use Bow\Database\Barry\Model;
class OrderModel extends Model
{
protected string $table = 'orders';
}
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:
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:
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.
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);
}
}
// 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 onebind()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 β
OrderWasPlacedlets 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.