Build an HTTP write workflow
The reference app's POST /api/client/orders validates at the HTTP boundary and enters PlaceOrder once. The UseCase owns the transaction; OrderResource owns the JSON shape.
This workflow uses a Controller, DTO, Action, Event, Query, Domain, and Domain Exception.
Before you begin
Run PULSAR_CORE_PATH=../../../core composer run reference:reset from examples/reference-app, then read the inbound adapter rule and transaction ownership.
Build the write path
StoreOrderRequest authorizes an authenticated client and validates the input. The Controller converts only validated data to a Domain DTO, calls one UseCase, and shapes the delivery-neutral result.
examples/reference-app/app/Pulsar/Services/Client/Modules/Orders/Controllers/OrderController.php — HTTP boundary calls one UseCase.
<?php
namespace App\Pulsar\Services\Client\Modules\Orders\Controllers;
use App\Pulsar\Domain\Orders\DTOs\CreateOrderData;
use App\Pulsar\Domain\Orders\Models\Order;
use App\Pulsar\Services\Client\Modules\Orders\Requests\StoreOrderRequest;
use App\Pulsar\Services\Client\Modules\Orders\Resources\OrderResource;
use App\Pulsar\Services\Client\Modules\Orders\UseCases\PlaceOrder;
use App\Pulsar\Services\Client\Modules\Orders\UseCases\ViewOrder;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Gate;
final class OrderController
{
// #region order-store-controller
public function store(StoreOrderRequest $request, PlaceOrder $useCase): JsonResponse
{
$order = $useCase->execute(new CreateOrderData(
tenantId: $request->user()->tenant_id,
reference: $request->string('reference')->toString(),
amountCents: $request->integer('amount_cents'),
));
return (new OrderResource($order))->response()->setStatusCode(201);
}
// #endregion
// #region order-show-controller
public function show(Order $order, ViewOrder $useCase): OrderResource
{
Gate::authorize('view', $order);
return new OrderResource($useCase->execute($order->id));
}
// #endregion
}examples/reference-app/app/Pulsar/Services/Client/Modules/Orders/UseCases/PlaceOrder.php — Transaction and after-commit Event ownership.
<?php
namespace App\Pulsar\Services\Client\Modules\Orders\UseCases;
use App\Pulsar\Domain\Orders\Actions\CreateOrder;
use App\Pulsar\Domain\Orders\DTOs\CreateOrderData;
use App\Pulsar\Domain\Orders\Events\OrderPlaced;
use App\Pulsar\Domain\Orders\Models\Order;
use Illuminate\Support\Facades\DB;
final class PlaceOrder
{
public function __construct(private readonly CreateOrder $createOrder) {}
// #region place-order-workflow
public function execute(CreateOrderData $data): Order
{
return DB::transaction(function () use ($data): Order {
$order = $this->createOrder->execute($data);
DB::table('outbox_messages')->insert([
'event_type' => 'orders.placed.v1',
'aggregate_id' => (string) $order->id,
'payload' => json_encode(['order_id' => $order->id, 'version' => OrderPlaced::VERSION], JSON_THROW_ON_ERROR),
'idempotency_key' => 'order-placed-'.$order->id,
'created_at' => now(),
'updated_at' => now(),
]);
event(new OrderPlaced($order->id));
return $order;
});
}
// #endregion
}Interpretation. Validation and response assembly stay outside the workflow; the Action persists one aggregate inside the UseCase transaction.
Verify failures and success
Run composer run reference:test:http. The group proves unauthenticated rejection, validation errors, a 201 response, the JSON response fields, and an authorization denial. A duplicate reference fails inside the transaction and rolls the write back; map a Domain Exception in bootstrap/app.php, never in a Domain type.
Troubleshoot
If a Controller reaches a Query or Action, move that call into the UseCase. Do not return an HTTP response from a Domain type.
Related tasks
Continue with read-only endpoints or review Request and Resource.