PHPackages                             gilsegura/psr-messages-bundle - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [API Development](/categories/api)
4. /
5. gilsegura/psr-messages-bundle

ActiveSymfony-bundle[API Development](/categories/api)

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&lt;-&gt;PSR bridge, and adds a #\[Pipeline\] attribute plus an exception subscriber for JSON:API error responses.

1.1.0(1mo ago)06MITPHPPHP ^8.4CI passing

Since Jun 18Pushed 1mo agoCompare

[ Source](https://github.com/gilsegura/psr-messages-bundle)[ Packagist](https://packagist.org/packages/gilsegura/psr-messages-bundle)[ RSS](/packages/gilsegura-psr-messages-bundle/feed)WikiDiscussions main Synced 2w ago

READMEChangelogDependencies (40)Versions (6)Used By (0)

PSR MESSAGES BUNDLE
===================

[](#psr-messages-bundle)

[![tests](https://github.com/gilsegura/psr-messages-bundle/actions/workflows/tests.yaml/badge.svg)](https://github.com/gilsegura/psr-messages-bundle/actions/workflows/tests.yaml)[![codecov](https://camo.githubusercontent.com/549599f1ad3fb8dce6d20175fc9dc109dfafdbcf4c649c1e0a3b42f826dea207/68747470733a2f2f636f6465636f762e696f2f6769746875622f67696c7365677572612f7073722d6d657373616765732d62756e646c652f67726170682f62616467652e737667)](https://codecov.io/github/gilsegura/psr-messages-bundle)[![static analysis](https://github.com/gilsegura/psr-messages-bundle/actions/workflows/static-analysis.yaml/badge.svg)](https://github.com/gilsegura/psr-messages-bundle/actions/workflows/static-analysis.yaml)[![coding standards](https://github.com/gilsegura/psr-messages-bundle/actions/workflows/coding-standards.yaml/badge.svg)](https://github.com/gilsegura/psr-messages-bundle/actions/workflows/coding-standards.yaml)

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
------------

[](#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
------------

[](#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
--------------------

[](#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-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)
--------------------

[](#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

[](#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
---------------------------------

[](#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
--------------------------

[](#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 */
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
-----------

[](#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

[](#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

[](#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
-------

[](#license)

MIT. See [LICENSE](LICENSE).

###  Health Score

42

—

FairBetter than 88% of packages

Maintenance93

Actively maintained with recent releases

Popularity6

Limited adoption so far

Community2

Small or concentrated contributor base

Maturity55

Maturing project, gaining track record

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~2 days

Total

5

Last Release

36d ago

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/29969702?v=4)[josué](/maintainers/gilsegura)[@gilsegura](https://github.com/gilsegura)

###  Code Quality

TestsPHPUnit

Static AnalysisPHPStan, Rector

Code StylePHP CS Fixer

Type Coverage Yes

### Embed Badge

![Health badge](/badges/gilsegura-psr-messages-bundle/health.svg)

```
[![Health](https://phpackages.com/badges/gilsegura-psr-messages-bundle/health.svg)](https://phpackages.com/packages/gilsegura-psr-messages-bundle)
```

###  Alternatives

[drupal/core-recommended

Locked core dependencies; require this project INSTEAD OF drupal/core.

6943.5M442](/packages/drupal-core-recommended)[easycorp/easyadmin-bundle

Admin generator for Symfony applications

4.3k18.3M413](/packages/easycorp-easyadmin-bundle)[flow-php/flow

PHP ETL - Extract Transform Load - Data processing framework

86337.5k](/packages/flow-php-flow)[sylius/sylius

E-Commerce platform for PHP, based on Symfony framework.

8.5k6.0M763](/packages/sylius-sylius)[symfony/framework-bundle

Provides a tight integration between Symfony components and the Symfony full-stack framework

3.6k257.3M12.1k](/packages/symfony-framework-bundle)[drupal/core

Drupal is an open source content management platform powering millions of websites and applications.

21667.3M1.9k](/packages/drupal-core)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
