gilsegura/psr-messages-bundle

Symfony integration for gilsegura/psr-messages: registers the parsers, schema resolver, response factories and PSR-15 middlewares as services, wires the Symfony<->PSR bridge, and adds a #[Pipeline] attribute plus an exception subscriber for JSON:API error responses.

Maintainers

Package info

github.com/gilsegura/psr-messages-bundle

Type:symfony-bundle

pkg:composer/gilsegura/psr-messages-bundle

Transparency log

Statistics

Installs: 6

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

1.1.0 2026-06-28 14:34 UTC

This package is auto-updated.

Last update: 2026-07-28 14:50:24 UTC


README

tests codecov static analysis coding standards

Symfony bundle that turns a controller into a PSR-15 pipeline. Each controller declares, with a #[Pipeline] attribute, the middlewares that should run around it — body parsing, header/query parsing and request/response validation — using the real middlewares from the gilsegura/psr-messages, gilsegura/psr-validator and gilsegura/psr-server packages. The controller is the terminal PSR-15 handler; the pipeline only adds middlewares in front of it.

How it works

#[Pipeline]  →  configurations (plain data; each names its factory)
                     ↓  PipelineResolver  (memoized per controller)
              ($factory)($configuration)   ← factory is an invokable service
                     ↓
              real psr-* middleware  (stateless, reused per worker)
                     ↓
              MiddlewareRunner  [ terminal handler = your controller ]

A kernel.controller subscriber reads the #[Pipeline], builds the MiddlewareRunner once per controller class (memoized for the lifetime of the worker), converts the Symfony request to PSR-7, runs the pipeline and converts the PSR-7 response back. A controller with no #[Pipeline] simply runs with no middlewares — it is still the terminal handler.

Installation

composer require gilsegura/psr-messages-bundle

Register the bundle (or let Symfony Flex do it):

// config/bundles.php
return [
    Psr\Messages\Bundle\PsrMessagesBundle::class => ['all' => true],
];

Writing a controller

Extend AbstractController and declare the pipeline. Controllers are autoconfigured as Symfony controllers, so you only route to them as usual.

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Messages\Bundle\Attribute\Pipeline;
use Psr\Messages\Bundle\Controller\AbstractController;
use Psr\Messages\Bundle\Pipeline\Config\HeadersConfiguration;
use Psr\Messages\Bundle\Pipeline\Config\MediaTypeParser;
use Psr\Messages\Bundle\Pipeline\Config\ParsedBodyConfiguration;
use Psr\Messages\Bundle\Pipeline\Config\Validation\BodyValidationConfiguration;
use Psr\Messages\Bundle\Pipeline\Config\Validation\RequestValidationConfiguration;
use Psr\Messages\Bundle\Pipeline\Config\ValidationConfiguration;

#[Pipeline(
    new HeadersConfiguration(RequestHeaders::class),
    new ValidationConfiguration(
        new RequestValidationConfiguration(new BodyValidationConfiguration('create-user')),
    ),
    new ParsedBodyConfiguration(MediaTypeParser::JSON_API, CreateUserRequest::class),
)]
final readonly class PostUserController extends AbstractController
{
    #[\Override]
    public function __invoke(ServerRequestInterface $request): ResponseInterface
    {
        $body = $request->getParsedBody();   // a CreateUserRequest, already parsed
        // ...
    }
}

The order of the configurations in #[Pipeline] is the order the middlewares run in.

Building the response — presenting a model into a resource, applying sparse fieldsets with withFieldset(), assembling a document and paginating with Pagination — is the job of the psr-messages package; see its README for the document model. This bundle only wires that toolkit into Symfony: the pipeline, schema validation, and the PSR bridge.

The two kinds of schema

The bundle deliberately resolves schemas in two different ways, because they are two different things:

  • Parsing (ParsedBodyConfiguration, HeadersConfiguration, QueryParamsConfiguration) references a SchemaInterface class by its class-string. That class deserializes the raw input into a typed object; it is resolved inline (no container needed).
  • Validation (BodyValidationConfiguration, etc.) references a JSON Schema file by an alias. The bundle scans the schema directory, registers a FileFactory service per .json, and the validation factory resolves it by that alias.

So a parser turns the body into a CreateUserRequest, while validation checks the raw body against create-user.json — independent pieces.

Schemas (validation)

JSON Schema files live in config/schemas/ by default. The file name (without .json) is the alias you pass to the validation configurations:

config/schemas/create-user.json   →  new BodyValidationConfiguration('create-user')

Override the directory:

// config/packages/psr_messages.php
return static function (Symfony\Config\PsrMessagesConfig $config): void {
    $config->schemaDir('%kernel.project_dir%/config/schemas');
};

The directory is registered as a resource, so adding or changing a .json invalidates the container in dev.

Request vs response validation

Validation runs in two halves. Request validation is the trust boundary and always runs — it checks the incoming, untrusted payload against its schema. Response validation checks the application's own output against its schema; it is a development safety net that is redundant in production once the presenters are trusted, and it is the costlier half of validation.

Response validation is therefore wired to kernel.debug: it runs in dev and test and is off in production. No configuration is needed — the ValidationMiddlewareFactory receives kernel.debug and builds an empty response validator in prod, which passes the response through untouched. The phase breakdown below shows this is the single largest warm-path saving in prod.

Each schema is read and compiled from disk only once per worker: a CachedSchemaFactory memoizes the compiled schema, so validating any number of requests within a worker reuses the same compiled schema rather than recompiling it per request.

Mapping exceptions to HTTP status

The bundle is exception-agnostic: it ships no status mappers. Provide your own by implementing ExceptionStatusMapper — it is autoconfigured by tag and collected by the exception subscriber, which renders a JSON:API error response. The first mapper that returns a Status wins; unmapped exceptions fall back to 500.

use Psr\Messages\Bundle\Exception\ExceptionStatusMapper;
use Psr\Server\ResponseFactory\Status;

final readonly class ValidationStatusMapper implements ExceptionStatusMapper
{
    #[\Override]
    public function statusFor(\Throwable $throwable): ?Status
    {
        return $throwable instanceof ValidationExceptionInterface
            ? Status::UNPROCESSABLE_CONTENT
            : null;
    }
}

Adding your own middleware

Implement a MiddlewareConfiguration (plain data that names its factory) and a MiddlewareFactory (an invokable that builds the PSR-15 middleware). The factory is autoconfigured by tag, so it is reachable from any #[Pipeline] without touching the bundle.

use Psr\Http\Server\MiddlewareInterface;
use Psr\Messages\Bundle\Pipeline\MiddlewareConfiguration;
use Psr\Messages\Bundle\Pipeline\MiddlewareFactory;

final readonly class RateLimitConfiguration implements MiddlewareConfiguration
{
    public function __construct(public int $perMinute = 60) {}

    #[\Override]
    public static function factory(): string
    {
        return RateLimitMiddlewareFactory::class;
    }
}

/** @implements MiddlewareFactory<RateLimitConfiguration> */
final readonly class RateLimitMiddlewareFactory implements MiddlewareFactory
{
    #[\Override]
    public function __invoke(MiddlewareConfiguration $configuration): MiddlewareInterface
    {
        return new RateLimitMiddleware($configuration->perMinute);
    }
}

Then reference it in a pipeline: #[Pipeline(new RateLimitConfiguration(120), ...)].

Performance

The reflection of the #[Pipeline] and the MiddlewareRunner are built once per controller class and reused for the lifetime of the worker. The first request to a controller pays that one-off cost; every later request is a cached lookup plus the actual middleware work (validation/parsing).

Benchmarks

Two benchmarks are included. Run both with Xdebug off — Xdebug in develop or coverage mode inflates timings several times over.

make benchmark reports the whole-request overhead: the cold cost (first call: reflection + building the runner) and the warm, steady-state cost once the runner is memoized per controller.

make benchmark-phases breaks the warm path into its stages (PSR bridge in, header/body parsing, request validation, response validation, controller, PSR bridge out) so you can see where the time goes before optimizing anything. Each middleware is timed for its own work only, so the phases are additive. Pass the environment to compare dev and prod:

make benchmark-phases          # dev/test: response validation runs
make benchmark-phases-prod     # prod: response validation off

What the breakdown shows

On a POST /things (body + request and response validation), the warm path is dominated by validation; the document model (presenting and serializing in the controller) is a small fraction:

                  test (debug=on)   prod (debug=off)
validate request       48%               69%        the trust boundary; always runs
validate response      29%                0%        off in prod (wired to kernel.debug)
psr bridge in/out      14%               19%        Symfony <-> PSR conversion
parse headers/body       4%                6%
controller               5%                6%        present + serialize + document

Two conclusions drive where it is worth optimizing:

  • Disabling response validation in prod is the single biggest saving (~30% of the warm pipeline) and it is already on by default via kernel.debug.
  • Request validation is the irreducible cost — the schema is already compiled once per worker, so what remains is the intrinsic work of validating the input against the compiled schema. It only shrinks by simplifying the schemas themselves; measure before assuming it helps.

The phase total is only the pipeline. End to end, a request is dominated by the Symfony HttpKernel (routing, events, kernel lifecycle); the pipeline is a small share of it. The controller — where presenters, sparse fieldsets and the document model live — is consistently the cheapest measured phase, so optimizing it further is not worthwhile.

License

MIT. See LICENSE.