Start here
Quick Start Architecture Components Frameworks Contributing Coding StandardFramework-Neutral Quick Start¶
Build one order-processing path from a command to a post-commit event and a follow-up command. The example keeps business language in your code and uses Fight Common only for the messaging, transaction, and adapter seams.
Pick your framework¶
The portable example below works without a framework. If you prefer to begin from a complete application skeleton, choose the repository that matches your runtime.
Starter release status
The five starter repositories are the intended shortest path once their 1.0.0
releases are published. They are still being prepared for that release, so treat these clone
commands as a preview of the coming workflow—not a current stable-install promise.
git clone https://github.com/johnnickell/project-symfony.git my-app
Laravelgit clone https://github.com/johnnickell/project-laravel.git my-app
Yiigit clone https://github.com/johnnickell/project-yii.git my-app
CodeIgnitergit clone https://github.com/johnnickell/project-codeigniter.git my-app
Slimgit clone https://github.com/johnnickell/project-slim.git my-app
For today, start with PHP 8.5+ and Composer:
The supporting domain type¶
Messages should carry your own validated domain types. OrderId rejects malformed
identifiers at construction, so every command, event, and handler receives an identifier it can trust.
src/Domain/Order/OrderId.php
<?php
declare(strict_types=1);
namespace App\Domain\Order;
use Fight\Common\Domain\Exception\DomainException;
final readonly class OrderId
{
private function __construct(private string $value)
{
}
public static function fromString(string $value): self
{
if (preg_match('/\AORDER-[1-9][0-9]*\z/D', $value) !== 1) {
throw new DomainException('Order IDs must use the ORDER-<number> format.');
}
return new self($value);
}
public function toString(): string
{
return $this->value;
}
}
The complete journey also uses application-owned CustomerId,
PaymentMethod, ShoppingCart, Order, repository ports, and
payment and fulfillment ports. Those types describe the order domain; they are not Fight Common classes.
The command¶
ProcessOrder is the request to perform work. Implementing Fight's Command
contract makes it serializable for synchronous or asynchronous buses without putting routing logic
inside the message.
src/Domain/Order/ProcessOrder.php
<?php
declare(strict_types=1);
namespace App\Domain\Order;
use Fight\Common\Domain\Exception\DomainException;
use Fight\Common\Domain\Messaging\Command\Command;
final readonly class ProcessOrder implements Command
{
public function __construct(
public CustomerId $customerId,
public OrderId $orderId,
public PaymentMethod $paymentMethod,
) {
}
/** @param array<string, mixed> $data */
public static function fromArray(array $data): static
{
return new self(
CustomerId::fromString(self::requiredString($data, 'customer_id')),
OrderId::fromString(self::requiredString($data, 'order_id')),
PaymentMethod::tokenized(self::requiredString($data, 'payment_method')),
);
}
/** @return array{customer_id: string, order_id: string, payment_method: string} */
public function toArray(): array
{
return [
'customer_id' => $this->customerId->toString(),
'order_id' => $this->orderId->toString(),
'payment_method' => $this->paymentMethod->providerToken(),
];
}
/** @param array<string, mixed> $data */
private static function requiredString(array $data, string $key): string
{
if (!isset($data[$key]) || !is_string($data[$key])) {
throw new DomainException(sprintf('ProcessOrder requires a string "%s".', $key));
}
return $data[$key];
}
}
The event¶
OrderProcessed records the business fact that the order was saved successfully. It
carries the order identity—not fulfillment instructions—so downstream application code can decide
what happens next.
src/Domain/Order/OrderProcessed.php
<?php
declare(strict_types=1);
namespace App\Domain\Order;
use Fight\Common\Domain\Exception\DomainException;
use Fight\Common\Domain\Messaging\Event\Event;
final readonly class OrderProcessed implements Event
{
public function __construct(public OrderId $orderId)
{
}
/** @param array<string, mixed> $data */
public static function fromArray(array $data): static
{
if (!isset($data['order_id']) || !is_string($data['order_id'])) {
throw new DomainException('OrderProcessed requires a string "order_id".');
}
return new self(OrderId::fromString($data['order_id']));
}
/** @return array{order_id: string} */
public function toArray(): array
{
return ['order_id' => $this->orderId->toString()];
}
}
The command handler¶
ProcessOrderHandler coordinates the use case. It loads the cart, asks the payment port
for a stable reference, saves the order transactionally, and only then triggers
OrderProcessed. Dispatching the event after commitTransactional() returns
keeps subscribers from observing an order that was rolled back.
src/Application/Order/ProcessOrderHandler.php
<?php
declare(strict_types=1);
namespace App\Application\Order;
use App\Domain\Order\Order;
use App\Domain\Order\OrderProcessed;
use App\Domain\Order\OrderRepository;
use App\Domain\Order\PaymentProcessor;
use App\Domain\Order\ProcessOrder;
use App\Domain\Order\ShoppingCartRepository;
use Fight\Common\Application\Messaging\Command\CommandHandler;
use Fight\Common\Application\Messaging\Event\EventDispatcher;
use Fight\Common\Application\Repository\TransactionalUnitOfWork;
use Fight\Common\Domain\Messaging\Command\CommandMessage;
final readonly class ProcessOrderHandler implements CommandHandler
{
public function __construct(
private ShoppingCartRepository $carts,
private OrderRepository $orders,
private PaymentProcessor $payments,
private TransactionalUnitOfWork $unitOfWork,
private EventDispatcher $events,
) {
}
public static function commandRegistration(): string
{
return ProcessOrder::class;
}
public function handle(CommandMessage $commandMessage): void
{
$command = $commandMessage->payload();
if (!$command instanceof ProcessOrder) {
throw new \LogicException('ProcessOrderHandler received an unsupported command.');
}
$cart = $this->carts->getForCustomer($command->customerId);
$paymentReference = $this->payments->process($cart->total(), $command->paymentMethod);
$this->unitOfWork->commitTransactional(
function () use ($command, $cart, $paymentReference): void {
$this->orders->save(Order::fromCart($command->orderId, $cart, $paymentReference));
},
);
$this->events->trigger(new OrderProcessed($command->orderId));
}
}
The follow-up command¶
Fulfillment is separate work with its own retry boundary. FulfillOrder carries only the
order ID, so it can be dispatched immediately or transported to a worker later.
src/Domain/Order/FulfillOrder.php
<?php
declare(strict_types=1);
namespace App\Domain\Order;
use Fight\Common\Domain\Exception\DomainException;
use Fight\Common\Domain\Messaging\Command\Command;
final readonly class FulfillOrder implements Command
{
public function __construct(public OrderId $orderId)
{
}
/** @param array<string, mixed> $data */
public static function fromArray(array $data): static
{
return new self(OrderId::fromString(self::requiredString($data, 'order_id')));
}
/** @return array{order_id: string} */
public function toArray(): array
{
return ['order_id' => $this->orderId->toString()];
}
/** @param array<string, mixed> $data */
private static function requiredString(array $data, string $key): string
{
if (!isset($data[$key]) || !is_string($data[$key])) {
throw new DomainException(sprintf('FulfillOrder requires a string "%s".', $key));
}
return $data[$key];
}
}
The event subscriber¶
The subscriber translates the completed business fact into the next application request. That translation belongs in Application code: the event stays factual and the Domain remains unaware of command routing.
src/Application/Order/OrderProcessedSubscriber.php
<?php
declare(strict_types=1);
namespace App\Application\Order;
use App\Domain\Order\FulfillOrder;
use App\Domain\Order\OrderProcessed;
use Fight\Common\Application\Messaging\Command\CommandBus;
use Fight\Common\Application\Messaging\Event\EventSubscriber;
use Fight\Common\Domain\Messaging\Event\EventMessage;
final readonly class OrderProcessedSubscriber implements EventSubscriber
{
public function __construct(private CommandBus $commands)
{
}
public static function eventRegistration(): array
{
return [OrderProcessed::class => 'onOrderProcessed'];
}
public function onOrderProcessed(EventMessage $eventMessage): void
{
$event = $eventMessage->payload();
if (!$event instanceof OrderProcessed) {
throw new \LogicException('OrderProcessedSubscriber received an unsupported event.');
}
$this->commands->execute(new FulfillOrder($event->orderId));
}
}
The fulfillment handler¶
FulfillOrderHandler reloads authoritative order state and verifies the stored payment
reference before requesting fulfillment. A pending or failed payment stops the workflow instead of
turning an uncertain payment into an irreversible external action.
src/Application/Order/FulfillOrderHandler.php
<?php
declare(strict_types=1);
namespace App\Application\Order;
use App\Domain\Order\FulfillOrder;
use App\Domain\Order\FulfillmentRequester;
use App\Domain\Order\OrderRepository;
use App\Domain\Order\PaymentNotSuccessful;
use App\Domain\Order\PaymentProcessor;
use App\Domain\Order\PaymentStatus;
use Fight\Common\Application\Messaging\Command\CommandHandler;
use Fight\Common\Domain\Messaging\Command\CommandMessage;
final readonly class FulfillOrderHandler implements CommandHandler
{
public function __construct(
private OrderRepository $orders,
private PaymentProcessor $payments,
private FulfillmentRequester $fulfillment,
) {
}
public static function commandRegistration(): string
{
return FulfillOrder::class;
}
public function handle(CommandMessage $commandMessage): void
{
$command = $commandMessage->payload();
if (!$command instanceof FulfillOrder) {
throw new \LogicException('FulfillOrderHandler received an unsupported command.');
}
$order = $this->orders->get($command->orderId);
if ($this->payments->status($order->paymentReference()) !== PaymentStatus::SUCCEEDED) {
throw new PaymentNotSuccessful(
sprintf('Payment %s is not successful.', $order->paymentReference()->toString()),
);
}
$this->fulfillment->request($order);
}
}
Wire the application¶
Composition is the Adapter edge. Register the two handlers, connect the event subscriber, and replace the demonstration repositories and external-service fakes with adapters selected by your application.
config/order-processing.php
$customerId = CustomerId::fromString('CUSTOMER-42');
$orderId = OrderId::fromString('ORDER-1001');
$cart = ShoppingCart::forCustomer(
$customerId,
Item::create('COFFEE-BEANS', 1800, 1),
Item::create('FILTERS', 700, 2),
);
$carts = new InMemoryShoppingCartRepository($cart);
$orders = new InMemoryOrderRepository();
$payments = new FakePaymentProcessor(
PaymentReference::fromString('PAYMENT-1001'),
PaymentStatus::SUCCEEDED,
);
$unitOfWork = new DemoTransactionalUnitOfWork([$orders]);
$fulfillment = new FakeFulfillmentRequester();
$router = new InMemoryCommandRouter();
$commands = new RoutingCommandBus($router);
$events = new SimpleEventDispatcher();
$events->register(new OrderProcessedSubscriber($commands));
$router->registerHandlers([
ProcessOrder::class => new ProcessOrderHandler($carts, $orders, $payments, $unitOfWork, $events),
FulfillOrder::class => new FulfillOrderHandler($orders, $payments, $fulfillment),
]);
Dispatch the command¶
The entry point creates application-owned values and sends the command through Fight's public
CommandBus. The caller does not select a handler directly.
public/process-order.php
$commands->execute(
new ProcessOrder(
$customerId,
$orderId,
PaymentMethod::tokenized('provider-token-for-customer-42'),
),
);
For the deterministic documentation fixture, the result is:
Where to go next¶
- Read Architecture for the Adapter → Application → Domain dependency rule.
- Read Messaging for buses, routers, handlers, and delivery semantics.
- Read Repositories for transactional persistence boundaries.
- Review Framework Support before selecting framework adapters.