Skip to content

Middleware Is Not a Layer

A controller reaches into middleware to run business logic. It looks convenient. The middleware has the session, the user, the request body, everything you need. So why bounce through a handler when you can just validate the cart right there? Because the moment you do, your business rule only runs inside an HTTP pipeline, and you just lost the ability to invoke it from anywhere else.

The Problem

PSR-15 middleware is a pipeline for HTTP concerns. It is what Slim, Mezzio, Laminas, and many Laravel setups use to process a request before it hits the controller. Each middleware wraps the next one and decides whether to pass the request through, short-circuit with a response, or decorate the outgoing response on the way back.

That pipeline is a transport mechanism. Its job is to prepare the HTTP request and finalize the HTTP response. Parsing the body, starting the session, handling errors, appending a Content-Length header: all of these make sense in a pipeline because they all deal with the shape of HTTP itself.

The trouble starts when "convenient access to the request" gets read as "a good place to put logic". A middleware that validates a shopping cart, computes a price, or writes an entity to the database is no longer transport. It is application logic that happens to live in a PSR-15 class. You cannot call it from a CLI command. You cannot call it from a queue consumer. You cannot even test it without constructing a ServerRequestInterface and a fake RequestHandlerInterface.

Middleware that holds business logic couples your domain to HTTP. The protocol becomes a dependency of your rules.

The Mechanism

Phexium draws the line explicitly. Middleware exists in two places, and neither of them owns business logic.

The framework ships one middleware: SessionMiddleware. It starts the session before the request is handled and saves it after:

namespace Phexium\Presentation\Middleware;

final readonly class SessionMiddleware implements MiddlewareInterface
{
    public function __construct(private SessionInterface $session) {}

    public function process(
        ServerRequestInterface $request,
        RequestHandlerInterface $handler
    ): ResponseInterface {
        if (!$this->session->isStarted()) {
            $this->session->start();
        }

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

        $this->session->save();

        return $response;
    }
}

That is transport. The middleware makes sure the session lifecycle matches the request lifecycle. Nothing more.

The demo app adds two more middlewares. UserContextMiddleware reads the session and attaches a user context to the request so downstream code can pick it up as a request attribute. RbacPermissionMiddleware is a per-route guard: it checks that the authenticated user has a named permission and returns 403 otherwise. Routes opt in explicitly:

$app->post('/books', CreateBookController::class.':createBook')
    ->add($rbac->forPermission('book.create'));

Both middlewares deal with authentication and authorization plumbing. They decide whether the request is allowed to proceed, but they do not decide what the request means in business terms.

Everywhere else, business logic lives in Command and Query handlers behind a bus. A controller builds a Command from the HTTP request and dispatches it. The handler neither knows nor cares that an HTTP request existed:

namespace AppDemo\Library\Application\Command;

final readonly class CreateBookHandler implements CommandHandlerInterface
{
    public function __construct(
        private BookRepository $bookRepository,
        private EventBusInterface $eventBus,
        private ClockInterface $clock,
    ) {}

    public function __invoke(CreateBookCommand $command): void
    {
        $book = new Book(
            $command->id,
            Title::fromString($command->title),
            Author::fromString($command->author),
            ISBN::fromString($command->isbn)
        );

        $this->bookRepository->save($book);

        $this->eventBus->dispatch(
            new BookCreatedEvent($command->id, $this->clock->now(), $book)
        );
    }
}

The handler takes a plain DTO and writes domain state. It has no idea who called it. A Slim controller, a Behat step, a CLI command, a queue worker: all of them build the same Command and dispatch it to the same handler.

Trade-offs

This split costs you one layer of indirection on every write path. A developer used to doing the work in a FormRequest or a middleware will see Controller -> Bus -> Handler -> Domain and ask why three hops are needed when one would do.

The honest answer is that you do not need three hops if your only goal is to respond to HTTP. You need them the moment you want to reuse the same rule from somewhere else, or test it without simulating HTTP. The indirection is the price you pay to decouple the rule from the protocol. If your application has exactly one entry point and will never grow another, a fat middleware is cheaper.

For anything with a CLI tool, a scheduled job, a queue consumer, an admin command, or a second HTTP entry point next to the first one, the split pays for itself on the first reuse.

What This Enables

Once business logic lives in handlers and middleware stays on HTTP concerns, a few things fall into place.

Tests construct handlers directly. There is no ServerRequestInterface to mock, no middleware stack to walk through, no PSR-7 noise in assertions. You instantiate the handler with real collaborators, an in-memory repository and an in-memory event bus, dispatch a Command, and assert on the domain state.

A new entry point reuses the same handler. Adding a JSON API next to the web controllers means writing a new controller that builds the same Command and dispatches it. You do not reimplement validation, persistence, or event dispatch.

Swapping the HTTP framework becomes tractable. SessionMiddleware is tied to PSR-15 because sessions are an HTTP concern. CreateBookHandler is not tied to anything except its collaborators. The day you wrap the same handlers in a different HTTP stack, the Application layer does not move.

Middleware stays useful. It remains the right place for HTTP cross-cutting concerns: session lifecycle, user context extraction, permission gates, error handling via the framework's own ErrorMiddleware. Treating it as transport rather than as an architectural layer is what keeps it useful.