- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: System Design Pocket Guide: Fundamentals
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You've shipped the feature. The HTTP route is locked down. There's a Gate::authorize('refund', $order) line at the top of the controller. Code review passed. The Laravel Policy has unit tests. Everything is green.
Six months later somebody opens a Jira ticket: "we need to refund orders from the back-office CLI." Two weeks after that, the payment provider starts sending refund-completed webhooks that have to flip the same order state. A queue worker shows up to retry failed refunds in batch.
Three new entry points. Same business operation. Same authorization rules. The controller has one of them. The other three either copy-paste the Policy call, or (more often) forget it entirely and ship a php artisan refund:order command that lets any operator with shell access refund anyone's order without a check.
The auth logic was in the wrong layer. That's the whole bug. And it's a bug that the framework's own conventions push you into.
The framework example that teaches you the wrong place
Open the Laravel docs for authorization. The canonical example puts the check in the controller:
namespace App\Http\Controllers;
use App\Models\Order;
use Illuminate\Http\Request;
final class RefundController
{
public function store(Request $request, Order $order)
{
$this->authorize('refund', $order);
$order->refund($request->integer('amount_cents'));
return response()->noContent();
}
}
Symfony's documentation does the same thing with Voters and the #[IsGranted] attribute on the controller method:
namespace App\Controller;
use App\Entity\Order;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
final class RefundController extends AbstractController
{
#[Route('/orders/{id}/refund', methods: ['POST'])]
#[IsGranted('REFUND', subject: 'order')]
public function refund(Order $order, Request $request): Response
{
$amount = (int) $request->request->get('amount_cents');
$order->refund($amount);
return new Response('', Response::HTTP_NO_CONTENT);
}
}
Both examples are correct as far as the HTTP layer goes. Both teach the wrong layer.
The controller is an adapter. It translates an HTTP request into a call against your application. Putting the rule "only a manager can refund an order over 500 EUR" inside the adapter says: this rule exists because of HTTP. Which it does not. It exists because the business needs it. HTTP is one of the rooms where the rule applies.
The day a second entry point arrives
Here is the same RefundOrder operation reached from php artisan. No middleware. No $request->user(). No Policy invocation, because nothing in the Symfony or Laravel example told the engineer that the auth check belonged anywhere except the controller.
namespace App\Console\Commands;
use App\Models\Order;
use Illuminate\Console\Command;
final class RefundOrderCommand extends Command
{
protected $signature = 'refund:order
{order_id}
{amount_cents : Refund amount in cents}
{--operator= : Operator initiating the refund}';
public function handle(): int
{
$order = Order::findOrFail($this->argument('order_id'));
$order->refund((int) $this->argument('amount_cents'));
$this->info("Refunded {$order->id}");
return self::SUCCESS;
}
}
The --operator= flag is informational. Nothing in this code verifies the operator is allowed. Anyone with shell access on the box can refund anything. The Policy that gates the HTTP route is not invoked. The Voter is not invoked. The authorize() helper is not even available — there's no Auth::user() in a console context unless you wired one up.
The same hole exists when a queue worker processes a RefundOrderJob you dispatched from somewhere else, and when an inbound webhook from the payment provider calls OrderService::refund() directly because "the webhook is trusted." The webhook handler ships without the check three months after the CLI did.
The OWASP API Security Top 10 (2023) lists "broken function level authorization" as risk #5. The pattern is exactly this: the rule lives only on the public HTTP path, and an internal path skips it.
Move the rule to where the rule lives
A use case is one business operation. The rule that gates it belongs inside it. If the rule is "you must be a manager to refund over 500 EUR," it belongs inside RefundOrder, not on top of one of the doors that lead to it.
Three pieces:
- An
AuthorizationPort: an interface the use case depends on. It does not know aboutAuth::user(), JWTs, sessions, or HTTP headers. It just answers "can this actor do this action on this subject?" - An
Actorvalue object: who is asking. Each adapter builds one its own way (HTTP from the session, CLI from the OS user, queue from whoever dispatched the job, webhook from a verified signature plus a service identity). - The use case itself, which takes an
Actoras part of its input and calls the port before doing anything mutating.
namespace App\Domain\Auth;
interface AuthorizationPort
{
public function authorize(Actor $actor, string $action, object $subject): void;
}
final readonly class Actor
{
public function __construct(
public string $id,
public string $kind,
public array $roles,
) {}
public static function system(string $name): self
{
return new self(id: "system:{$name}", kind: 'system', roles: ['system']);
}
}
final class NotAuthorized extends \DomainException {}
Actor::system() is the escape hatch for things like the payment-provider webhook, where the caller is a verified service rather than a human. It still goes through the same port; it just produces an actor whose roles include system. The port decides what system can do, the same way it decides what manager can do.
The use case looks like this:
namespace App\UseCase;
use App\Domain\Auth\Actor;
use App\Domain\Auth\AuthorizationPort;
use App\Domain\Order\OrderRepository;
final readonly class RefundOrder
{
public function __construct(
private OrderRepository $orders,
private AuthorizationPort $auth,
) {}
public function execute(
Actor $actor,
string $orderId,
int $amountCents,
): void {
$order = $this->orders->getById($orderId);
$this->auth->authorize($actor, 'order.refund', $order);
$order->refund($amountCents);
$this->orders->save($order);
}
}
One authorization line. It runs no matter who called execute(). The use case does not know HTTP exists; it does not know Artisan exists; it does not know a queue exists. It knows about an Actor, an order, and a port.
The adapter (Laravel Policy) implements the port
The Laravel Policy doesn't disappear. It moves behind the port. Same code, different caller:
namespace App\Infrastructure\Auth;
use App\Domain\Auth\Actor;
use App\Domain\Auth\AuthorizationPort;
use App\Domain\Auth\NotAuthorized;
use App\Models\Order;
use App\Policies\OrderPolicy;
final readonly class LaravelPolicyAuthorization implements AuthorizationPort
{
public function __construct(private OrderPolicy $policy) {}
public function authorize(Actor $actor, string $action, object $subject): void
{
$allowed = match (true) {
$action === 'order.refund' && $subject instanceof Order
=> $this->policy->refund($actor, $subject),
default => false,
};
if (! $allowed) {
throw new NotAuthorized("{$actor->id} cannot {$action}");
}
}
}
The Policy still encodes the business rules: managers, refund caps, regional restrictions. It is now invoked from the use case, not from the controller. Symfony Voters work the same way: wrap Security::isGranted() (or call the voter directly) inside an AuthorizationPort implementation and bind it in the container.
The controller becomes pure routing:
final class RefundController
{
public function __construct(private RefundOrder $useCase) {}
public function store(Request $request, string $orderId): Response
{
$this->useCase->execute(
actor: HttpActor::fromRequest($request),
orderId: $orderId,
amountCents: $request->integer('amount_cents'),
);
return response()->noContent();
}
}
Five lines: parse input, build an actor, call the use case. No authorize() because the use case owns it.
The CLI command now fails closed
final class RefundOrderCommand extends Command
{
protected $signature = 'refund:order
{order_id} {amount_cents} {--operator=}';
public function __construct(private RefundOrder $useCase)
{
parent::__construct();
}
public function handle(): int
{
$operatorId = $this->option('operator')
?? throw new \RuntimeException('--operator is required');
$actor = CliActor::fromOperatorId($operatorId);
$this->useCase->execute(
actor: $actor,
orderId: $this->argument('order_id'),
amountCents: (int) $this->argument('amount_cents'),
);
$this->info("Refunded {$this->argument('order_id')}");
return self::SUCCESS;
}
}
CliActor::fromOperatorId() looks the operator up in the user table and returns an Actor with their real roles, or it throws. If the operator isn't a manager, RefundOrder::execute() will throw NotAuthorized before the order is touched. The hole closes by construction.
Queue jobs follow the same shape: serialize the actor into the job payload (id + roles, not the whole user; keep it small and verify on rehydrate), build an Actor on the worker side, hand it to the use case. Webhook handlers verify the provider signature, then call the use case with Actor::system('stripe-webhook'), and the port enforces what system can do.
Two questions you should expect
"Doesn't this duplicate Laravel's middleware?" No. Middleware handles transport: rate limiting, CSRF, login state. It does not handle operation rules like "is this user allowed to refund this specific order over this specific amount." Keep middleware for transport, use cases for operations. They don't fight.
"What about Form Requests / DTOs that authorize?" Laravel's FormRequest::authorize() and similar Symfony patterns are still HTTP-only. The CLI doesn't run FormRequest. The queue worker doesn't run it. The webhook handler doesn't run it. They are HTTP-layer ergonomics, not authorization homes. Use them for transport-level input validation; keep the rule in the use case.
What you keep, what you drop
Keep:
- One
AuthorizationPortinterface per application, in the domain layer. - Concrete
LaravelPolicyAuthorizationorSymfonyVoterAuthorizationadapters that bind the framework's existing policy code to that port. - An
Actorvalue object every entry point knows how to construct. - Every use case that mutates state calls
$auth->authorize(...)early, before any I/O. - A test per use case that asserts
NotAuthorizedis thrown for a non-privileged actor.
Drop:
-
$this->authorize(...)calls inside controllers. -
#[IsGranted]attributes on controller methods, when the use case behind them is reachable from any non-HTTP path. - The unspoken assumption that "internal" callers (CLI, queue, webhook) are trusted because they're internal. Internal callers ship authorization regressions to production. They get caught last.
The rule lives next to the operation it gates. That keeps it fresh the moment a second entry point arrives.
If this was useful
The same shape (port in the domain, adapter at the edge, use case in the middle) handles transactions, observability, error translation, and the rest of the patterns that fail the day a second caller turns up. Decoupled PHP walks the full layout for Laravel 11 and Symfony 7 services, with migrations from framework-coupled code to a domain that outlives the framework.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now; Portuguese and Spanish coming soon.



Top comments (0)