DEV Community

Cover image for One Use Case, Three Entry Points: HTTP, CLI, and Queue Workers Sharing the Same Code
Gabriel Anhaia
Gabriel Anhaia

Posted on

One Use Case, Three Entry Points: HTTP, CLI, and Queue Workers Sharing the Same Code


You wrote CreateOrderController. Then product asked for a CSV importer, so you wrote ImportOrdersCommand and pasted the controller logic into a handle() method. Then sales asked for a "place order" button in the admin panel that ran async, so you wrote CreateOrderJob and pasted the same logic a third time.

Three copies of the same validation. Three copies of the same DB::transaction wrapper. Three copies of "what happens when inventory is out of stock." Bug reports come in for the HTTP path. You fix them there. Two weeks later, the same bug surfaces from the CSV importer because nobody remembered there were two other copies.

This is the smell that hexagonal architecture exists to solve. Not a trait, not a base controller. The fix is a single class that does not know how it was called, fronted by three thin adapters that translate input into the shape it wants.

You can do this in any framework. The example is Laravel because it's the most common case, but the pattern survives a Symfony or Slim port unchanged.

The use case is just a class

A use case is a regular PHP class. One public method. One input DTO. One output DTO. No Request, no Command, no Job. It does not know what called it and it does not care.

<?php

declare(strict_types=1);

namespace App\Domain\Order\UseCase;

use App\Domain\Order\Entity\Order;
use App\Domain\Order\Exception\InsufficientStock;
use App\Domain\Order\Port\OrderRepository;
use App\Domain\Order\Port\InventoryChecker;
use App\Domain\Order\Port\Clock;

final class CreateOrderUseCase
{
    public function __construct(
        private readonly OrderRepository $orders,
        private readonly InventoryChecker $inventory,
        private readonly Clock $clock,
    ) {}

    public function execute(CreateOrderInput $input): CreateOrderOutput
    {
        foreach ($input->items as $item) {
            if (!$this->inventory->hasStock($item->sku, $item->quantity)) {
                throw new InsufficientStock($item->sku);
            }
        }

        $order = Order::place(
            customerId: $input->customerId,
            items: $input->items,
            placedAt: $this->clock->now(),
        );

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

        return new CreateOrderOutput(
            orderId: $order->id(),
            totalCents: $order->totalCents(),
            placedAt: $order->placedAt(),
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

The input and output DTOs are plain readonly objects. No framework type hints.

<?php

declare(strict_types=1);

namespace App\Domain\Order\UseCase;

use App\Domain\Order\ValueObject\OrderItem;

final readonly class CreateOrderInput
{
    /** @param OrderItem[] $items */
    public function __construct(
        public string $customerId,
        public array $items,
        public ?string $idempotencyKey = null,
    ) {}
}

final readonly class CreateOrderOutput
{
    public function __construct(
        public string $orderId,
        public int $totalCents,
        public \DateTimeImmutable $placedAt,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

Notice what is missing. There is no Illuminate\Http\Request. No Symfony\Component\Console\Input\InputInterface. No Illuminate\Contracts\Queue\Job. The use case takes a CreateOrderInput and returns a CreateOrderOutput. That's the contract every adapter has to honor.

The ports — OrderRepository, InventoryChecker, Clock — are interfaces in the domain. The Eloquent or Doctrine implementation lives in App\Infrastructure. The use case never imports from App\Infrastructure. Run a composer require audit or a static check (deptrac, phparkitect) to enforce that line.

Three inbound adapters feed one core use case

Adapter one: the HTTP controller

The controller's job is translation. HTTP request comes in, JSON gets validated, a CreateOrderInput gets built, the use case runs, the output gets shaped into a JSON response. Six lines of real work.

<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Domain\Order\Exception\InsufficientStock;
use App\Domain\Order\UseCase\CreateOrderInput;
use App\Domain\Order\UseCase\CreateOrderUseCase;
use App\Domain\Order\ValueObject\OrderItem;
use App\Http\Requests\CreateOrderRequest;
use Illuminate\Http\JsonResponse;

final class CreateOrderController
{
    public function __construct(
        private readonly CreateOrderUseCase $useCase,
    ) {}

    public function __invoke(CreateOrderRequest $request): JsonResponse
    {
        $input = new CreateOrderInput(
            customerId: $request->validated('customer_id'),
            items: array_map(
                fn (array $i) => new OrderItem(
                    sku: $i['sku'],
                    quantity: $i['quantity'],
                    priceCents: $i['price_cents'],
                ),
                $request->validated('items'),
            ),
            idempotencyKey: $request->header('Idempotency-Key'),
        );

        try {
            $output = $this->useCase->execute($input);
        } catch (InsufficientStock $e) {
            return new JsonResponse(
                ['error' => 'out_of_stock', 'sku' => $e->sku],
                status: 409,
            );
        }

        return new JsonResponse([
            'order_id' => $output->orderId,
            'total_cents' => $output->totalCents,
            'placed_at' => $output->placedAt->format(DATE_ATOM),
        ], status: 201);
    }
}
Enter fullscreen mode Exit fullscreen mode

The CreateOrderRequest is a Laravel FormRequest with the validation rules. Validation belongs at the boundary. Keep it in the form request, not the use case. If the request fails validation, the use case never runs. That is the correct split.

Domain exceptions become HTTP status codes here, not in the use case. InsufficientStock is the domain's word for what happened. 409 Conflict is HTTP's word for it. The translation belongs in the adapter.

Adapter two: the Artisan command

A finance team wants to bulk-import orders from a CSV file every morning. Same business rules. Same persistence. Same stock check. The framework is now php artisan, not Nginx.

<?php

declare(strict_types=1);

namespace App\Console\Commands;

use App\Domain\Order\Exception\InsufficientStock;
use App\Domain\Order\UseCase\CreateOrderInput;
use App\Domain\Order\UseCase\CreateOrderUseCase;
use App\Domain\Order\ValueObject\OrderItem;
use Illuminate\Console\Command;

final class ImportOrdersCommand extends Command
{
    protected $signature = 'orders:import {file}';
    protected $description = 'Import orders from a CSV file';

    public function handle(CreateOrderUseCase $useCase): int
    {
        $path = $this->argument('file');
        $handle = fopen($path, 'r');
        if ($handle === false) {
            $this->error("Cannot open {$path}");
            return self::FAILURE;
        }

        $header = fgetcsv($handle);
        $ok = 0;
        $failed = 0;

        while (($row = fgetcsv($handle)) !== false) {
            $record = array_combine($header, $row);

            $input = new CreateOrderInput(
                customerId: $record['customer_id'],
                items: [new OrderItem(
                    sku: $record['sku'],
                    quantity: (int) $record['quantity'],
                    priceCents: (int) $record['price_cents'],
                )],
            );

            try {
                $output = $useCase->execute($input);
                $this->line("OK {$output->orderId}");
                $ok++;
            } catch (InsufficientStock $e) {
                $this->warn("SKIP {$record['customer_id']} sku={$e->sku}");
                $failed++;
            }
        }

        fclose($handle);
        $this->info("Imported {$ok}, skipped {$failed}");
        return self::SUCCESS;
    }
}
Enter fullscreen mode Exit fullscreen mode

The CSV parsing, the per-row reporting, the exit code — those are CLI concerns. They live in the command. The business rule "an order with out-of-stock items cannot be placed" lives in the use case, exactly where it lived for the HTTP path. When product changes the rule next quarter to allow backorders, you change one class. Both adapters pick up the new behavior the moment the test suite goes green.

There's no DB::table('orders')->insert(...) in this command. There's no validation rule duplicated from the form request. The command is thin on purpose.

Adapter three: the queue worker

The admin panel needs a "Place order on behalf of customer" button that fires async — same business rules, but on a worker process, retryable, observable.

<?php

declare(strict_types=1);

namespace App\Jobs;

use App\Domain\Order\Exception\InsufficientStock;
use App\Domain\Order\UseCase\CreateOrderInput;
use App\Domain\Order\UseCase\CreateOrderUseCase;
use App\Domain\Order\ValueObject\OrderItem;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Psr\Log\LoggerInterface;

final class CreateOrderJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 5;
    public int $backoff = 30;

    /** @param array<int, array{sku: string, quantity: int, priceCents: int}> $items */
    public function __construct(
        public readonly string $customerId,
        public readonly array $items,
        public readonly ?string $idempotencyKey = null,
    ) {}

    public function handle(
        CreateOrderUseCase $useCase,
        LoggerInterface $log,
    ): void {
        $input = new CreateOrderInput(
            customerId: $this->customerId,
            items: array_map(
                fn (array $i) => new OrderItem(
                    sku: $i['sku'],
                    quantity: $i['quantity'],
                    priceCents: $i['priceCents'],
                ),
                $this->items,
            ),
            idempotencyKey: $this->idempotencyKey,
        );

        try {
            $output = $useCase->execute($input);
            $log->info('order.created', [
                'order_id' => $output->orderId,
                'source' => 'queue',
            ]);
        } catch (InsufficientStock $e) {
            $log->warning('order.skipped.out_of_stock', [
                'sku' => $e->sku,
                'customer_id' => $this->customerId,
            ]);
            $this->fail($e);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The job's payload is plain scalars and arrays. That matters because Laravel serializes job constructor arguments to the queue store (Redis, SQS, RabbitMQ) as JSON. A CreateOrderInput with a typed OrderItem value object would serialize fine on the way in and might choke on the way out if the worker process has slightly different code. Keep the wire format dumb. Rebuild the typed input inside handle().

The retry behavior ($tries = 5, $backoff = 30) is a queue concern and lives on the job. The use case stays oblivious. If you swap from database queue to SQS to RabbitMQ next year, the use case does not move.

Use case in the center, three flows around it: HTTP request, CLI invocation, queue message

Wiring it together

One binding in a Laravel service provider, and every adapter resolves the same use case:

<?php

declare(strict_types=1);

namespace App\Providers;

use App\Domain\Order\Port\Clock;
use App\Domain\Order\Port\InventoryChecker;
use App\Domain\Order\Port\OrderRepository;
use App\Infrastructure\Clock\SystemClock;
use App\Infrastructure\Inventory\EloquentInventoryChecker;
use App\Infrastructure\Persistence\EloquentOrderRepository;
use Illuminate\Support\ServiceProvider;

final class DomainServiceProvider extends ServiceProvider
{
    public array $bindings = [
        OrderRepository::class => EloquentOrderRepository::class,
        InventoryChecker::class => EloquentInventoryChecker::class,
        Clock::class => SystemClock::class,
    ];
}
Enter fullscreen mode Exit fullscreen mode

The HTTP controller, the Artisan command, and the queue job all receive the use case through constructor injection. Laravel's container builds CreateOrderUseCase once, passes it whichever ports the bindings point at, and hands it off.

In tests, you swap the bindings for in-memory fakes. The same use case runs under PHPUnit with an InMemoryOrderRepository and an AlwaysInStockChecker. You test the business rule once. You test each adapter once for its translation layer. You do not write three integration tests of the same "place an order" path.

What changes when the framework changes

Here is the payoff. Two years from now, you migrate from Laravel to Symfony, or you carve the order service out into a Slim microservice. The use case file does not change. The DTOs do not change. The ports do not change.

What changes:

  • The HTTP controller is rewritten as a Symfony controller. Same body, different framework types.
  • The Artisan command is rewritten as a Symfony console command, or a bin/import script.
  • The queue job is rewritten on top of whatever queue library the new stack uses — Symfony Messenger, RoadRunner jobs, raw Beanstalkd.
  • The Eloquent adapters are rewritten as Doctrine repositories, or hand-rolled PDO ones.

The order placement logic lives in one class: stock checks, the customer-and-at-least-one-item rule, the price-times-quantity total. All of it survives every migration. That is what "outlive the framework" means in practice.

Trade-offs worth naming

Three adapters cost more code than one fat controller. You write more files. You wire more bindings. The first feature in a greenfield service feels slower because there are four classes where a Laravel tutorial would have one.

The break-even is the second adapter. As soon as a second caller appears (a job, a CLI script, a webhook handler), duplication starts compounding. By the third place, the hexagonal layout is cheaper than the alternative. You get there faster than you'd think.

The other cost: junior engineers used to "open the controller, write the SQL" will need a tour. The tour is short — domain, port, adapter, three folders. Hexagonal architecture without DDD walks the same shape in Go if you want a second angle on the layout.

Skip this pattern when the service genuinely only ever has one entry point and the operation is a CRUD passthrough. Reach for it the moment a second caller shows up. Don't wait until the third copy of the validation rule has rotted before you consolidate.


If this was useful

Decoupled PHP walks the full pattern across a real e-commerce module: multiple use cases, multiple adapters per port, the testing layer, the migration path from a Laravel-flavored service into a framework-independent core. The HTTP / CLI / queue split is one chapter; the book covers the persistence, eventing, and outbound-integration sides of the same idea.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)