DEV Community

Cover image for PSR-7 Is the Right Shape for Your HTTP Adapter, Even in Laravel
Gabriel Anhaia
Gabriel Anhaia

Posted on

PSR-7 Is the Right Shape for Your HTTP Adapter, Even in Laravel


You opened a Laravel controller and saw this:

public function store(Request $request, OrderService $orders)
{
    $customerId = $request->user()->id;
    $items = $request->input('items', []);
    $idempotencyKey = $request->header('Idempotency-Key');

    $order = $orders->place($customerId, $items, $idempotencyKey);

    return response()->json($order, 201);
}
Enter fullscreen mode Exit fullscreen mode

It looks fine. It is also the reason that, two years from now, when someone proposes a Symfony rewrite or a Mezzio-based worker that consumes the same use case from a queue, the answer is "it would be nice, but the controllers know too much."

$request->input(...) is Laravel. $request->user() is Laravel's auth guard. response()->json(...) is Laravel's response factory. The OrderService did not ask for any of that. It needed a customer id, a list of items, and an idempotency key. The controller poured Laravel into the middle of the call site, and the use case absorbed the smell by accident.

The fix is not to throw out Laravel. The fix is to put a thin PSR-7 / PSR-15 shape at the edge of the HTTP adapter. The framework only owns the wire. Your application only sees an interface every framework agrees on.

What PSR-7 actually gives you

PSR-7 is the PHP-FIG specification for HTTP messages. It defines two interfaces you care about on the server side:

  • Psr\Http\Message\ServerRequestInterface: the incoming request, immutable, with typed accessors for method, URI, headers, body, parsed body, query params, cookies, uploaded files, and a generic attribute bag for middleware to attach context.
  • Psr\Http\Message\ResponseInterface: the outgoing response, also immutable, with withStatus(), withHeader(), and withBody() returning new instances.

PSR-15 sits on top of PSR-7 and defines MiddlewareInterface and RequestHandlerInterface. A handler takes a ServerRequestInterface and returns a ResponseInterface. That is the entire contract.

Slim, Mezzio, RoadRunner workers, and Laravel's own symfony/psr-http-message-bridge all speak this dialect. Once your inbound adapter returns a PSR-7 response, the framework wrapping it becomes a deployment detail.

PSR-7 boundary at the edge of the HTTP adapter

The use case does not know HTTP exists

Start from the inside out. The application layer exposes one verb: place an order. It takes a plain DTO, returns a plain DTO, and throws domain exceptions when the rules say no.

<?php
declare(strict_types=1);

namespace App\Application\Order;

final readonly class PlaceOrderInput
{
    /** @param list<OrderItemInput> $items */
    public function __construct(
        public string $customerId,
        public array $items,
        public ?string $idempotencyKey,
    ) {}
}

final readonly class OrderItemInput
{
    public function __construct(
        public string $sku,
        public int $quantity,
    ) {}
}

final readonly class PlaceOrderOutput
{
    public function __construct(
        public string $orderId,
        public int $totalCents,
        public string $currency,
    ) {}
}

interface PlaceOrder
{
    public function handle(PlaceOrderInput $input): PlaceOrderOutput;
}
Enter fullscreen mode Exit fullscreen mode

Nothing in here mentions HTTP. No Request, no headers, no status codes. The implementation lives in App\Application\Order\PlaceOrderHandler and depends on ports (OrderRepository, Clock, IdempotencyStore) defined next to it. None of those ports care about the framework either.

That is the target. The HTTP adapter's job is to convert a PSR-7 request into a PlaceOrderInput, dispatch it through the use case, and turn the result (or exception) into a PSR-7 response.

The PSR-15 handler in the middle

<?php
declare(strict_types=1);

namespace App\Infrastructure\Http\Order;

use App\Application\Order\OrderItemInput;
use App\Application\Order\PlaceOrder;
use App\Application\Order\PlaceOrderInput;
use App\Domain\Order\Exception\OutOfStock;
use App\Domain\Order\Exception\UnknownCustomer;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;

final readonly class PlaceOrderAction implements RequestHandlerInterface
{
    public function __construct(
        private PlaceOrder $placeOrder,
        private ResponseFactoryInterface $responses,
    ) {}

    public function handle(
        ServerRequestInterface $request
    ): ResponseInterface {
        $customerId = (string) $request->getAttribute('auth.user_id');
        if ($customerId === '') {
            return $this->json(401, ['error' => 'unauthenticated']);
        }

        $body = (array) $request->getParsedBody();
        $items = $this->parseItems($body['items'] ?? []);
        $key = $request->getHeaderLine('Idempotency-Key') ?: null;

        try {
            $out = $this->placeOrder->handle(new PlaceOrderInput(
                customerId: $customerId,
                items: $items,
                idempotencyKey: $key,
            ));
        } catch (UnknownCustomer) {
            return $this->json(404, ['error' => 'customer_not_found']);
        } catch (OutOfStock $e) {
            return $this->json(409, [
                'error' => 'out_of_stock',
                'sku'   => $e->sku,
            ]);
        }

        return $this->json(201, [
            'order_id'    => $out->orderId,
            'total_cents' => $out->totalCents,
            'currency'    => $out->currency,
        ]);
    }

    /** @return list<OrderItemInput> */
    private function parseItems(mixed $raw): array
    {
        if (!is_array($raw)) {
            return [];
        }
        $items = [];
        foreach ($raw as $row) {
            if (!is_array($row)) {
                continue;
            }
            $items[] = new OrderItemInput(
                sku: (string) ($row['sku'] ?? ''),
                quantity: (int) ($row['quantity'] ?? 0),
            );
        }
        return $items;
    }

    private function json(int $status, array $body): ResponseInterface
    {
        $resp = $this->responses->createResponse($status)
            ->withHeader('Content-Type', 'application/json');
        $resp->getBody()->write(json_encode($body, JSON_THROW_ON_ERROR));
        return $resp;
    }
}
Enter fullscreen mode Exit fullscreen mode

Read what is in there and what is not.

What is in there: a PSR-7 request, a PSR-17 response factory, a use-case interface, and domain exceptions translated to HTTP status codes. The mapping table lives at the boundary where it belongs: UnknownCustomer becomes a 404, OutOfStock becomes a 409. The use case never returns a status code.

What is not in there: anything from Illuminate\*, anything from Symfony\Component\HttpFoundation\*, any global helpers, any facade.

Run this handler under Slim, Mezzio, RoadRunner, or a custom PSR-15 dispatcher and it works without changes. The next section runs it under Laravel.

Running it inside Laravel

Laravel ships with Illuminate\Http\Request, which extends Symfony's Request. The composer package symfony/psr-http-message-bridge plus a PSR-17 implementation like nyholm/psr7 converts between Symfony HttpFoundation and PSR-7 in both directions. Laravel's docs cover this in the PSR-7 requests section.

Install:

composer require symfony/psr-http-message-bridge nyholm/psr7
Enter fullscreen mode Exit fullscreen mode

Register the PSR-17 bindings in a service provider:

<?php
declare(strict_types=1);

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Nyholm\Psr7\Factory\Psr17Factory;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ServerRequestFactoryInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Message\UploadedFileFactoryInterface;
use Psr\Http\Message\UriFactoryInterface;
use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory;

final class PsrHttpServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(Psr17Factory::class);

        foreach ([
            ResponseFactoryInterface::class,
            ServerRequestFactoryInterface::class,
            StreamFactoryInterface::class,
            UploadedFileFactoryInterface::class,
            UriFactoryInterface::class,
        ] as $iface) {
            $this->app->bind($iface, Psr17Factory::class);
        }

        $this->app->singleton(
            PsrHttpFactory::class,
            fn ($app) => new PsrHttpFactory(
                $app->make(Psr17Factory::class),
                $app->make(Psr17Factory::class),
                $app->make(Psr17Factory::class),
                $app->make(Psr17Factory::class),
            ),
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Now write one Laravel controller method that does nothing but bridge into the PSR-15 handler:

<?php
declare(strict_types=1);

namespace App\Http\Controllers;

use App\Infrastructure\Http\Order\PlaceOrderAction;
use Illuminate\Http\Request;
use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory;
use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory;

final class OrderController
{
    public function __construct(
        private readonly PlaceOrderAction $action,
        private readonly PsrHttpFactory $toPsr,
        private readonly HttpFoundationFactory $toLaravel,
    ) {}

    public function store(Request $request)
    {
        $psrRequest = $this->toPsr
            ->createRequest($request)
            ->withAttribute('auth.user_id', (string) $request->user()?->id);

        $psrResponse = $this->action->handle($psrRequest);

        return $this->toLaravel->createResponse($psrResponse);
    }
}
Enter fullscreen mode Exit fullscreen mode

The Laravel-specific work is now four lines. Pull user_id from Laravel's auth, pass the request into the PSR adapter, return the PSR response back through Laravel. Everything else lives in PlaceOrderAction, which has zero Illuminate imports.

The auth.user_id attribute is the seam. Laravel populates it from its guard; a Mezzio middleware would populate it from a JWT decoder; a queue consumer would populate it from the job payload. The use case never asks where it came from. That is the point of PSR-7 attributes. They are the framework-agnostic way to attach decoded context to a request.

The same action under Slim or Mezzio

Slim 4 routes are configured in plain PHP and accept a PSR-15 handler class directly:

<?php
declare(strict_types=1);

use App\Infrastructure\Http\Order\PlaceOrderAction;
use App\Infrastructure\Http\Auth\BearerTokenMiddleware;
use Slim\Factory\AppFactory;
use DI\Container;

require __DIR__ . '/../vendor/autoload.php';

$container = new Container();

AppFactory::setContainer($container);
$app = AppFactory::create();

$app->post('/orders', PlaceOrderAction::class)
    ->add(BearerTokenMiddleware::class);

$app->run();
Enter fullscreen mode Exit fullscreen mode

The BearerTokenMiddleware decodes the Authorization header and writes the user id onto the request:

public function process(
    ServerRequestInterface $request,
    RequestHandlerInterface $handler
): ResponseInterface {
    $userId = $this->tokens->decode(
        $request->getHeaderLine('Authorization')
    );
    return $handler->handle(
        $request->withAttribute('auth.user_id', $userId)
    );
}
Enter fullscreen mode Exit fullscreen mode

Mezzio looks almost identical. A RouteCollector registers the same PlaceOrderAction::class and a MiddlewarePipe runs the auth middleware. Neither framework needs the action to change.

That is the payoff. The HTTP wire format is Illuminate\Http\Request on one deployment and Slim\Psr7\Request on another, and the use case never finds out.

Same use case, three HTTP shells: Laravel, Slim, Mezzio

Testing the action without booting any framework

When the action is a RequestHandlerInterface, the test is a function call. No HTTP kernel, no router, no service container, no Laravel TestCase parent class.

<?php
declare(strict_types=1);

namespace Tests\Infrastructure\Http\Order;

use App\Application\Order\PlaceOrder;
use App\Application\Order\PlaceOrderInput;
use App\Application\Order\PlaceOrderOutput;
use App\Infrastructure\Http\Order\PlaceOrderAction;
use Nyholm\Psr7\Factory\Psr17Factory;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

final class PlaceOrderActionTest extends TestCase
{
    #[Test]
    public function it_returns_201_with_the_order_payload(): void
    {
        $psr17 = new Psr17Factory();

        $useCase = new class implements PlaceOrder {
            public function handle(PlaceOrderInput $input): PlaceOrderOutput
            {
                return new PlaceOrderOutput(
                    orderId: 'ord_42',
                    totalCents: 3000,
                    currency: 'EUR',
                );
            }
        };

        $action = new PlaceOrderAction($useCase, $psr17);

        $body = $psr17->createStream(json_encode([
            'items' => [['sku' => 'A', 'quantity' => 2]],
        ]));

        $request = $psr17->createServerRequest('POST', '/orders')
            ->withParsedBody(['items' => [
                ['sku' => 'A', 'quantity' => 2],
            ]])
            ->withAttribute('auth.user_id', 'cust_1')
            ->withBody($body);

        $response = $action->handle($request);

        self::assertSame(201, $response->getStatusCode());
        self::assertSame(
            'application/json',
            $response->getHeaderLine('Content-Type'),
        );

        $payload = json_decode(
            (string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR,
        );
        self::assertSame('ord_42', $payload['order_id']);
        self::assertSame(3000, $payload['total_cents']);
    }
}
Enter fullscreen mode Exit fullscreen mode

This test runs without booting a kernel. There is no RefreshDatabase, no withMiddleware, no $this->postJson(...), no service container. It is a unit test of the boundary, with a hand-rolled fake for the use case and a real nyholm/psr7 factory because PSR-7 implementations are cheap to construct.

You still want Laravel-integration tests for the controller bridge and the auth wiring. Those live in tests/Feature and use Laravel's HTTP test helpers. The point is that the bulk of the HTTP-adapter logic is now testable in a runtime any IDE will execute on save.

What you actually gained

The wire-and-bridge layer is a fixed cost: two composer packages and a service provider. Once paid, every new endpoint reads the same way — a PSR-15 handler, a ServerRequestInterface in, a ResponseInterface out, exceptions mapped at the edge.

The things you bought:

  • The framework is replaceable, in the literal sense. A second deployment of the same application as a Slim service for a workshop, or a Mezzio worker for an internal tool, takes a route file and a container config. The action classes move untouched.
  • The use cases are framework-clean. No facade, no Illuminate\Support\*, no request() helper. Grepping the application layer for Illuminate returns nothing, and that grep becomes a useful CI check.
  • The tests run without booting a kernel. PSR-7 objects are free to construct. Every action gets a unit test that exercises real parsing, real header reads, real status mapping, with no I/O. PSR-7 is also implemented by Guzzle, Slim, Laminas/Mezzio, Nyholm, and Bref, so when a new runtime appears next year, odds are it speaks PSR-7 on day one.

You are not abandoning Laravel. You are keeping it out of the code that describes what the business actually does. Laravel becomes the truck. The cargo is yours.


If this was useful

The PSR-7-at-the-edge pattern is one slice of a longer argument: that Laravel and Symfony are adapters, not applications, and a service that treats them that way still runs in production five years later when the framework version everyone bet on is two majors behind. Decoupled PHP walks the full shape: ports, use cases, entities, the four anti-patterns both architectures forbid, and a strangler migration from a legacy Laravel service to the same layout, without a freeze week.

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)