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.
Package info
github.com/gilsegura/psr-messages-bundle
Type:symfony-bundle
pkg:composer/gilsegura/psr-messages-bundle
Requires
- php: ^8.4
- gilsegura/psr-messages: ^1.0
- gilsegura/psr-server: ^1.0
- gilsegura/psr-validator: ^1.0
- php-http/discovery: ^1.19
- psr/container: ^2.0
- psr/http-factory-implementation: ^1.0
- symfony/dependency-injection: ^7.4 || ^8.1
- symfony/event-dispatcher: ^7.4 || ^8.1
- symfony/http-kernel: ^7.4 || ^8.1
- symfony/psr-http-message-bridge: ^7.4 || ^8.1
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.95
- nyholm/psr7: ^1.8
- phpstan/phpstan: ^2.1
- phpstan/phpstan-strict-rules: ^2.0
- phpstan/phpstan-symfony: ^2.0
- phpunit/phpunit: ^13.1
- rector/rector: ^2.4
- symfony/framework-bundle: ^7.4 || ^8.1
- symfony/phpunit-bridge: ^7.4 || ^8.1
- symfony/routing: ^7.4 || ^8.1
README
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 aSchemaInterfaceclass 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 aFileFactoryservice 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.