Skip to content

Build a read-only query endpoint

The reference app's GET /api/client/orders/{order} authorizes the Client at the Controller then calls one read-only UseCase. That UseCase calls FindOrder; it owns no transaction.

The route combines a Controller, Query, Model, Policy, and Resource.

Before you begin

Create an order through the HTTP write workflow. Read the Controller entrypoint rule and Query ownership rules.

Route the read through a UseCase

The Controller checks the Policy before it enters ViewOrder. The Resource converts the returned Model to JSON after the UseCase returns.

examples/reference-app/app/Pulsar/Services/Client/Modules/Orders/Controllers/OrderController.php — Authorized delivery boundary for an order read.

php
<?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/ViewOrder.php — Read-only UseCase delegates to one Query.

php
<?php

namespace App\Pulsar\Services\Client\Modules\Orders\UseCases;

use App\Pulsar\Domain\Orders\Models\Order;
use App\Pulsar\Domain\Orders\Queries\FindOrder;

final class ViewOrder
{
    public function __construct(private readonly FindOrder $findOrder) {}

    // #region view-order-workflow
    public function execute(int $orderId): Order
    {
        return $this->findOrder->execute($orderId);
    }
    // #endregion
}
An HTTP Controller calls one read-only UseCase, which calls one or more Queries and returns a delivery-neutral value for Resource or Inertia assembly without a transaction.
Read through a UseCase without a transaction. Reads retain the Controller-to-UseCase boundary while Queries stay inside the read-only workflow.

Interpretation. Authorization occurs at the inbound boundary, while Query execution remains inside the read-only workflow and Resource shaping stays outside it.

Verify access and response shape

Run composer run reference:test:http. The group asserts the authorized response fields and a forbidden response when the authenticated Client belongs to a different tenant.

Troubleshoot

Do not inject a Query into the Controller. A Controller calls the UseCase; the UseCase calls the Query and returns a delivery-neutral Model or value.

Read Query, Policy, and the HTTP write workflow.