skedli / http-middleware
PSR-15 middleware components for correlation ID propagation, structured logging, error handling, JWT authentication, and idempotency.
Requires
- php: ^8.5
- ext-sodium: *
- doctrine/dbal: ^4.4
- firebase/php-jwt: ^7.0
- psr/http-message: ^2.0
- psr/http-server-handler: ^1.0
- psr/http-server-middleware: ^1.0
- ramsey/uuid: ^4.9
- tiny-blocks/environment-variable: ^1.2
- tiny-blocks/http: ^6.0
- tiny-blocks/logger: ^1.3
Requires (Dev)
- ext-openssl: *
- ext-posix: *
- ergebnis/composer-normalize: ^2.52
- guzzlehttp/guzzle: ^7.9
- guzzlehttp/psr7: ^2.9
- infection/infection: ^0.33
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^13.1
- squizlabs/php_codesniffer: ^4.0
README
- Overview
- Installation
- How to use
- License
Overview
Provides PSR-15 middleware for HTTP requests, including correlation ID propagation, structured request/response logging, error handling, stateless JWT authentication, extensible health check support, and idempotency enforcement.
Built on top of PSR-15 and PSR-7, the
middleware can be used with any framework that supports the MiddlewareInterface and RequestHandlerInterface
standards.
Installation
composer require skedli/http-middleware
How to use
Correlation ID
The middleware reads the Correlation-Id header from the incoming request. If present and non-empty, it reuses the
value. Otherwise, it generates a new UUID v4. In both cases, the correlation ID is:
- Injected as a request attribute (
correlationId) for downstream handlers. - Added to the response as the
Correlation-Idheader.
Default usage
Create the middleware with CorrelationIdMiddleware::create() and register it in your application. The default provider
generates a UUID v4 when no Correlation-Id header is present.
<?php declare(strict_types=1); use Skedli\HttpMiddleware\CorrelationId\CorrelationIdMiddleware; $middleware = CorrelationIdMiddleware::create()->build();
In a Slim 4 application:
<?php declare(strict_types=1); use Skedli\HttpMiddleware\CorrelationId\CorrelationIdMiddleware; $app->add(CorrelationIdMiddleware::create()->build());
The correlation ID is accessible in any handler via the request attribute:
<?php declare(strict_types=1); use Skedli\HttpMiddleware\CorrelationId\CorrelationId; $correlationId = $request->getAttribute('correlationId'); # @var CorrelationId $correlationId $correlationId->toString(); # "550e8400-e29b-41d4-a716-446655440000"
Reusing an existing correlation ID
When the incoming request already contains the Correlation-Id header, the middleware preserves it instead of
generating a new one. This enables end-to-end traceability across service boundaries.
Client → BFF (generates Correlation-Id: req-abc-123)
→ Service X (reuses req-abc-123)
→ Service Y (reuses req-abc-123)
→ Service Z (reuses req-abc-123)
No additional configuration is needed. The middleware handles this automatically.
Custom provider
Implement the CorrelationIdProvider interface to replace the default UUID v4 generation strategy:
<?php declare(strict_types=1); use Skedli\HttpMiddleware\CorrelationId\CorrelationId; use Skedli\HttpMiddleware\CorrelationId\CorrelationIdProvider; final readonly class PrefixedCorrelationId implements CorrelationId { public function __construct(private string $value) { } public function toString(): string { return $this->value; } } final readonly class PrefixedCorrelationIdProvider implements CorrelationIdProvider { public function __construct(private string $prefix) { } public function generate(): CorrelationId { return new PrefixedCorrelationId(value: sprintf('%s-%s', $this->prefix, bin2hex(random_bytes(8)))); } }
Then configure the middleware with the custom provider:
<?php declare(strict_types=1); use Skedli\HttpMiddleware\CorrelationId\CorrelationIdMiddleware; $middleware = CorrelationIdMiddleware::create() ->withProvider(provider: new PrefixedCorrelationIdProvider(prefix: 'bff')) ->build();
Using the correlation ID in your own logs
The middleware exposes the correlation ID as a request attribute. To include it in your own log entries,
read the attribute and pass it through the PSR-3 $context array. Any PSR-3 logger works. The
tiny-blocks/logger package is one such option, since it implements Psr\Log\LoggerInterface.
<?php declare(strict_types=1); use Psr\Http\Message\ServerRequestInterface; use Psr\Log\LoggerInterface; use Skedli\HttpMiddleware\CorrelationId\CorrelationId; final readonly class CreateUserHandler { public function __construct(private LoggerInterface $logger) { } public function __invoke(ServerRequestInterface $request): void { # @var CorrelationId $correlationId $correlationId = $request->getAttribute('correlationId'); $this->logger->info( 'user.creating', ['correlation_id' => $correlationId->toString(), 'email' => 'john@example.com'] ); } }
Output formatting depends on the logger implementation.
level=INFO key=user.creating data={"correlation_id":"550e8400-e29b-41d4-a716-446655440000","email":"john@example.com"}
Request and response logging
The LogMiddleware provides structured logging for every HTTP request and response. It captures method, URI,
query parameters, body, status code, and duration automatically.
Default usage
Create the middleware with a tiny-blocks/logger instance:
<?php declare(strict_types=1); use Skedli\HttpMiddleware\LogMiddleware; $middleware = LogMiddleware::create()->withLogger(logger: $logger)->build();
In a Slim 4 application:
<?php declare(strict_types=1); use Skedli\HttpMiddleware\LogMiddleware; $app->add(LogMiddleware::create()->withLogger(logger: $logger)->build());
What is logged
The middleware logs two entries per request cycle, one for the incoming request and one for the outgoing **response **.
Request is always logged at info level:
level=INFO key=request data={"method":"POST","uri":"/api/users","query_parameters":{"page":"1"},"body":{"name":"John","email":"john@example.com"}}
Response is logged at info for success (2xx/3xx) and error for failures (4xx/5xx):
level=INFO key=response data={"method":"POST","uri":"/api/users","status_code":201,"duration_ms":45.32,"body":{"id":"550e8400","name":"John"}}
level=ERROR key=response data={"method":"POST","uri":"/api/users","status_code":422,"duration_ms":12.87,"body":{"error":"Validation failed"}}
The query_parameters and body fields are only included when present. A GET request without query parameters
or body produces a minimal log:
level=INFO key=request data={"method":"GET","uri":"/api/health"}
level=INFO key=response data={"method":"GET","uri":"/api/health","status_code":200,"duration_ms":1.04}
Automatic correlation ID binding
When used together with the CorrelationIdMiddleware, the LogMiddleware automatically binds the correlation ID
to the logger context. No additional configuration is needed, just register both middleware in the correct order:
<?php declare(strict_types=1); use Skedli\HttpMiddleware\CorrelationId\CorrelationIdMiddleware; use Skedli\HttpMiddleware\LogMiddleware; # In Slim 4, middleware executes in LIFO order (last added = first to run). # CorrelationIdMiddleware must run before LogMiddleware. $app->add(LogMiddleware::create()->withLogger(logger: $logger)->build()); $app->add(CorrelationIdMiddleware::create()->build());
The correlation ID is then automatically included in every log entry:
2026-02-24T10:00:00+00:00 component=identity correlation_id=550e8400-e29b-41d4-a716-446655440000 level=INFO key=request data={"method":"POST","uri":"/api/users","body":{"name":"John"}}
2026-02-24T10:00:00+00:00 component=identity correlation_id=550e8400-e29b-41d4-a716-446655440000 level=INFO key=response data={"method":"POST","uri":"/api/users","status_code":201,"duration_ms":45.32}
If the CorrelationIdMiddleware is not registered, the LogMiddleware works normally without the correlation ID.
Error handling
The ErrorMiddleware catches uncaught exceptions during request processing, maps them to structured JSON responses
using a consumer-provided ExceptionMapping, and optionally logs them. When the mapping returns null, the middleware
either returns a generic 500 Internal Server Error response (fallback enabled, the default) or rethrows the exception
(fallback disabled).
When used together with CorrelationIdMiddleware, the ErrorMiddleware automatically binds the correlation ID to the
error log context.
Default usage
withMapping() is required. Provide an ExceptionMapping implementation that converts domain exceptions into
MappedError instances.
<?php declare(strict_types=1); use Skedli\HttpMiddleware\Error\ErrorMiddleware; use Skedli\HttpMiddleware\Error\ExceptionMapping; use Skedli\HttpMiddleware\Error\MappedError; use Throwable; $mapping = new class implements ExceptionMapping { public function mapTo(Throwable $exception): ?MappedError { if ($exception instanceof NotFoundException) { return new MappedError( code: 'NOT_FOUND', status: 404, message: 'The requested resource was not found.' ); } return null; } }; $middleware = ErrorMiddleware::create() ->withMapping(mapping: $mapping) ->build();
Mapped response body:
{
"code": "NOT_FOUND",
"message": "The requested resource was not found."
}
Fallback response body (unmapped exception, fallback enabled by default):
{
"code": "INTERNAL_ERROR",
"message": "An unexpected error occurred."
}
Exception mapping
ExceptionMapping is the single required dependency. mapTo() receives the thrown exception and returns a
MappedError or null. Returning null defers to the fallback strategy.
MappedError carries four public fields:
| Field | Type | Description |
|---|---|---|
code |
string |
Machine-readable error code included in the response. |
status |
int |
HTTP response status code (400–599). |
message |
string |
Human-readable error description. |
headers |
array<string, string|string[]> |
Optional HTTP response headers (default []). |
To include response headers, pass headers when constructing a MappedError. The example below
adds Retry-After when login is throttled:
<?php declare(strict_types=1); use Skedli\HttpMiddleware\Error\ExceptionMapping; use Skedli\HttpMiddleware\Error\MappedError; use Throwable; $mapping = new class implements ExceptionMapping { public function mapTo(Throwable $exception): ?MappedError { if ($exception instanceof LoginThrottled) { return new MappedError( code: 'LOGIN_THROTTLED', status: 429, message: 'Too many failed login attempts. Please try again later.', headers: ['Retry-After' => (string) $exception->retryAfter->toSeconds()] ); } return null; } };
Error handling settings
ErrorHandlingSettings controls response detail exposure and logging verbosity:
| Setting | Default | Description |
|---|---|---|
logErrors |
false |
Enables logging of exceptions when a logger is provided. |
logErrorDetails |
false |
Includes exception class, file, line, and stack trace in the log context. |
displayErrorDetails |
false |
Includes exception class, file, line, and stack trace in the response body. |
Development: full visibility in response and logs:
<?php declare(strict_types=1); use Skedli\HttpMiddleware\Error\ErrorHandlingSettings; use Skedli\HttpMiddleware\Error\ErrorMiddleware; $middleware = ErrorMiddleware::create() ->withLogger(logger: $logger) ->withMapping(mapping: $mapping) ->withSettings(settings: ErrorHandlingSettings::from( logErrors: true, logErrorDetails: true, displayErrorDetails: true )) ->build();
Fallback response body when displayErrorDetails is true:
{
"code": "INTERNAL_ERROR",
"message": "An unexpected error occurred.",
"exception": "RuntimeException",
"file": "/app/src/Application/Handlers/UserCreatingHandler.php",
"line": 42,
"trace": [
"#0 /app/src/Driver/Http/Endpoints/User/CreateUser.php(25): ...",
"#1 /app/vendor/slim/slim/Slim/Handlers/Strategies/RequestResponseArgs.php(30): ..."
]
}
Production: log details, minimal response:
<?php declare(strict_types=1); use Skedli\HttpMiddleware\Error\ErrorHandlingSettings; use Skedli\HttpMiddleware\Error\ErrorMiddleware; $middleware = ErrorMiddleware::create() ->withLogger(logger: $logger) ->withMapping(mapping: $mapping) ->withSettings(settings: ErrorHandlingSettings::from( logErrors: true, logErrorDetails: true, displayErrorDetails: false )) ->build();
Log output:
correlation_id=550e8400-e29b-41d4-a716-446655440000 level=ERROR key=error data={"message":"Unexpected database error","exception":"RuntimeException","file":"/app/src/...","line":42,"trace":"#0 /app/src/..."}
Logging errors
Provide a tiny-blocks/logger instance and enable logErrors in the
settings. The logger alone is not sufficient. The logErrors setting must be explicitly enabled.
<?php declare(strict_types=1); use Skedli\HttpMiddleware\Error\ErrorHandlingSettings; use Skedli\HttpMiddleware\Error\ErrorMiddleware; $middleware = ErrorMiddleware::create() ->withMapping(mapping: $mapping) ->withLogger(logger: $logger) ->withSettings(settings: ErrorHandlingSettings::from( logErrors: true, logErrorDetails: false, displayErrorDetails: false )) ->build();
Log output:
correlation_id=550e8400-e29b-41d4-a716-446655440000 level=ERROR key=error data={"message":"Unexpected database error"}
If the CorrelationIdMiddleware is not registered, the ErrorMiddleware works normally without the correlation ID.
Declarative mapping table
ExceptionMappingTable is an alternative to writing a custom ExceptionMapping class. It is useful when there are
many exception types to map: rules are registered with a fluent builder instead of imperative if or match chains.
Registration order is preserved and the first matching rule wins.
<?php declare(strict_types=1); use RuntimeException; use Skedli\HttpMiddleware\Error\ExceptionMapping; use Skedli\HttpMiddleware\Error\ExceptionMappingTable; use Skedli\HttpMiddleware\Error\MappedError; use Throwable; final readonly class PaymentExceptionMapping implements ExceptionMapping { public function mapTo(Throwable $exception): ?MappedError { return ExceptionMappingTable::create() ->when(InvalidMetadata::class) ->mapsTo(code: 'INVALID_METADATA', status: 400, message: 'The metadata must be a valid JSON object.') ->whenAny([DomainTransactionNotFound::class, QueryTransactionNotFound::class]) ->mapsTo(code: 'TRANSACTION_NOT_FOUND', status: 404, message: 'Transaction not found.') ->when(InvalidMoneyCurrency::class) ->resolvesWith(resolver: fn(InvalidMoneyCurrency $exception) => new MappedError( code: 'INVALID_MONEY_CURRENCY', status: 422, message: sprintf('The currency <%s> is not supported.', $exception->value), )) ->whenSubclassOf(RuntimeException::class) ->mapsTo(code: 'GATEWAY_UNAVAILABLE', status: 502, message: 'The upstream gateway is currently unavailable.') ->mapTo(exception: $exception); } }
| Method | Description |
|---|---|
when(class-string<Throwable>) |
Exact-class match. |
whenAny(non-empty-list<class-string<Throwable>>) |
Exact match against any class in the list. |
whenSubclassOf(class-string<Throwable>) |
instanceof match (catches subclasses too). |
mapsTo(code, status, message, headers?) |
Fixed MappedError. |
resolvesWith(Closure) |
Closure receives the matched exception and returns a MappedError. |
Building the table inside mapTo is convenient and stateless per call, but the table is rebuilt on every invocation.
Long-running runtimes can cache the built table as a property or a static variable if profiling shows it matters.
Per-group usage
By default, unmapped exceptions produce a generic 500 fallback response. Set withFallbackOnUnmapped(false) on an
inner middleware to propagate unmapped exceptions outward instead, so a wider scope can handle them.
<?php declare(strict_types=1); use Skedli\HttpMiddleware\Error\ErrorMiddleware; # Inner scope: maps domain exceptions, rethrows anything unrecognized. $inner = ErrorMiddleware::create() ->withMapping(mapping: $domainMapping) ->withFallbackOnUnmapped(fallbackOnUnmapped: false) ->build(); # Outer scope: catches everything that escaped the inner middleware. $outer = ErrorMiddleware::create() ->withMapping(mapping: $globalMapping) ->build();
The outer middleware acts as the catch-all. The inner middleware handles only the exceptions it recognizes and lets the
rest propagate. When withFallbackOnUnmapped(false) is set, the exception is rethrown before any logging occurs.
Authentication
The AuthenticationMiddleware enforces stateless token-based authentication on incoming requests. It extracts the
Bearer token from the Authorization header, validates it using a TokenDecoder, and propagates the authenticated
user context as a request attribute.
No database access is performed validation relies exclusively on the token's cryptographic signature and its claims.
Default usage
Configure the middleware with a signing algorithm and key material. The built-in JwtTokenDecoder handles JWT
validation using firebase/php-jwt.
With RSA (asymmetric):
<?php declare(strict_types=1); use Skedli\HttpMiddleware\Authentication\AuthenticationMiddleware; use Skedli\HttpMiddleware\Authentication\SigningAlgorithm; $middleware = AuthenticationMiddleware::create() ->withAlgorithm(algorithm: SigningAlgorithm::RS256) ->withKeyMaterial(keyMaterial: $publicKey) ->build();
With HMAC (symmetric):
<?php declare(strict_types=1); use Skedli\HttpMiddleware\Authentication\AuthenticationMiddleware; use Skedli\HttpMiddleware\Authentication\SigningAlgorithm; $middleware = AuthenticationMiddleware::create() ->withAlgorithm(algorithm: SigningAlgorithm::HS256) ->withKeyMaterial(keyMaterial: 'your-shared-secret-key') ->build();
In a Slim 4 application:
<?php declare(strict_types=1); use Skedli\HttpMiddleware\Authentication\AuthenticationMiddleware; use Skedli\HttpMiddleware\Authentication\SigningAlgorithm; $app->add( AuthenticationMiddleware::create() ->withAlgorithm(algorithm: SigningAlgorithm::RS256) ->withKeyMaterial(keyMaterial: $publicKey) ->build() );
Using a JWKS endpoint
In a microservices architecture, the Identity Provider (IdP) typically exposes its public keys through a JWKS endpoint. The middleware can fetch the public key directly from this endpoint, eliminating the need to manually distribute key material across services.
The JWKS fetch goes through a Skedli\HttpMiddleware\Authentication\JwksProvider supplied by the consumer. The library
does not bundle a concrete HTTP client. Use HttpJwksProvider::with(client:, factory:, jwksUrl:) to combine any PSR-18
client (Guzzle, Symfony HTTP Client, php-http/curl-client, etc.) with a PSR-17 request factory and the JWKS URL.
Timeouts, retries, and TLS configuration are owned by the consumer's client.
If the project already wires a PSR-18 client and a PSR-17 factory for another HTTP integration, the same instances can
be passed to HttpJwksProvider::with(...). The signature is intentionally compatible with
NetworkTransport::with(client:, factory:) from tiny-blocks/http, so a project already using that package can share
the same client and factory arguments. Timeouts, retries, and TLS configuration remain owned by the shared client.
<?php declare(strict_types=1); use GuzzleHttp\Client; use GuzzleHttp\Psr7\HttpFactory; use Skedli\HttpMiddleware\Authentication\AuthenticationMiddleware; use Skedli\HttpMiddleware\Authentication\HttpJwksProvider; $middleware = AuthenticationMiddleware::create() ->withJwksProvider(jwksProvider: HttpJwksProvider::with( client: new Client(), factory: new HttpFactory(), jwksUrl: 'http://identity-web/v1/keys/public' )) ->build();
The JWKS fetch is lazy. No network call is made during build(). The public key is resolved on the first incoming
request and cached for the lifetime of the middleware instance. The algorithm defaults to RS256 when not explicitly
set.
In a Slim 4 application with an environment variable:
<?php declare(strict_types=1); use GuzzleHttp\Client; use GuzzleHttp\Psr7\HttpFactory; use Skedli\HttpMiddleware\Authentication\AuthenticationMiddleware; use Skedli\HttpMiddleware\Authentication\HttpJwksProvider; # $jwksUrl = your way of loading IDENTITY_JWKS_URL $app->add( AuthenticationMiddleware::create() ->withJwksProvider(jwksProvider: HttpJwksProvider::with( client: new Client(), factory: new HttpFactory(), jwksUrl: $jwksUrl )) ->build() );
When the JWKS endpoint is unreachable or returns an invalid response, the middleware returns a 401 Unauthorized
response with a descriptive error message.
When authentication fails, the middleware returns a 401 Unauthorized response:
{
"code": "UNAUTHORIZED",
"message": "Token has expired."
}
Possible error messages:
| Message | Cause |
|---|---|
Missing Authorization header. |
The request has no Authorization header. |
Authorization header must use Bearer scheme. |
The header does not start with Bearer . |
Bearer token is empty. |
The header is Bearer with no token value. |
Token is invalid or could not be decoded. |
The token is malformed or the signature does not match. |
Token has expired. |
The token exp claim is in the past. |
Token is missing the subject (sub) claim. |
The token has no sub claim. |
Failed to fetch JWKS from <url>: <reason>. |
The JWKS endpoint is unreachable or timed out. |
Invalid JWKS response from <url>. |
The JWKS JSON has no keys. |
JWKS response does not contain a valid RSA key (...). |
The JWKS key is missing the n or e fields. |
Supported algorithms
The SigningAlgorithm enum defines the supported algorithms:
| Algorithm | Type | Use case |
|---|---|---|
RS256 |
RSA | Public/private key (asymmetric). |
RS384 |
RSA | Public/private key (asymmetric). |
RS512 |
RSA | Public/private key (asymmetric). |
HS256 |
HMAC | Shared secret (symmetric). |
HS384 |
HMAC | Shared secret (symmetric). |
HS512 |
HMAC | Shared secret (symmetric). |
ES256 |
ECDSA | Elliptic curve (asymmetric). |
ES384 |
ECDSA | Elliptic curve (asymmetric). |
Accessing the authenticated user
On successful authentication, the middleware injects an AuthenticatedUser instance as a request attribute. The
AuthenticatedUser is an interface with three methods:
<?php declare(strict_types=1); use Skedli\HttpMiddleware\Authentication\AuthenticatedUser; use Skedli\HttpMiddleware\Authentication\AuthenticationMiddleware; # @var AuthenticatedUser $user $user = $request->getAttribute(AuthenticationMiddleware::AUTHENTICATED_USER_ATTRIBUTE); $user->userId(); # "e3b0c442-98fc-1c14-b39f-f32d831cb27a" $user->issuedAt(); # 1740000000 $user->expiresAt(); # 1740003600
Custom token decoder
Implement the TokenDecoder interface to replace the built-in JWT validation with your own strategy. The decoder
must validate the token locally (stateless), without performing any network call or database query.
<?php declare(strict_types=1); use Skedli\HttpMiddleware\Authentication\AuthenticatedUser; use Skedli\HttpMiddleware\Authentication\TokenDecoder; use Skedli\HttpMiddleware\Authentication\TokenValidationFailed; final readonly class OpaqueTokenDecoder implements TokenDecoder { public function __construct(private TokenStore $tokenStore) { } public function decode(string $token): AuthenticatedUser { $claims = $this->tokenStore->lookup($token); if ($claims === null) { throw TokenValidationFailed::withReason(reason: 'Token not found.'); } return MyAuthenticatedUser::from(claims: $claims); } }
Then configure the middleware with the custom decoder:
<?php declare(strict_types=1); use Skedli\HttpMiddleware\Authentication\AuthenticationMiddleware; $middleware = AuthenticationMiddleware::create() ->withTokenDecoder(tokenDecoder: new OpaqueTokenDecoder($tokenStore)) ->build();
Custom authenticated user
The AuthenticatedUser is an interface, so you can extend it with additional claims specific to your domain.
Return your custom implementation from your TokenDecoder:
<?php declare(strict_types=1); use Skedli\HttpMiddleware\Authentication\AuthenticatedUser; final readonly class TenantAwareUser implements AuthenticatedUser { public function __construct( private array $roles, private string $userId, private int $issuedAt, private int $expiresAt, private string $tenantId ) { } public function expiresAt(): int { return $this->expiresAt; } public function issuedAt(): int { return $this->issuedAt; } public function roles(): array { return $this->roles; } public function tenantId(): string { return $this->tenantId; } public function userId(): string { return $this->userId; } }
Access the extended claims in your handler:
<?php declare(strict_types=1); use Skedli\HttpMiddleware\Authentication\AuthenticationMiddleware; # @var TenantAwareUser $user $user = $request->getAttribute(AuthenticationMiddleware::AUTHENTICATED_USER_ATTRIBUTE); $user->userId(); # "e3b0c442-98fc-1c14-b39f-f32d831cb27a" $user->tenantId(); # "tenant-42" $user->roles(); # ["admin", "billing"]
Builder precedence
When multiple configuration options are provided, the builder resolves them in this order:
| Priority | Configuration | Behavior |
|---|---|---|
| 1st | withTokenDecoder(...) |
Custom decoder wins everything else is ignored. |
| 2nd | withJwksProvider(...) |
Fetches JWKS, converts to PEM, defaults to RS256. |
| 3rd | withKeyMaterial(...) + withAlgorithm(...) |
Uses PEM and algorithm directly. |
<?php declare(strict_types=1); use Skedli\HttpMiddleware\Authentication\AuthenticationMiddleware; use Skedli\HttpMiddleware\Authentication\SigningAlgorithm; # The custom decoder wins, JwksProvider, key material, and algorithm are ignored. # $jwksProvider = HttpJwksProvider::with(...) $middleware = AuthenticationMiddleware::create() ->withAlgorithm(algorithm: SigningAlgorithm::RS256) ->withKeyMaterial(keyMaterial: $publicKey) ->withJwksProvider(jwksProvider: $jwksProvider) ->withTokenDecoder(tokenDecoder: $customDecoder) ->build();
Building the middleware without a TokenDecoder, key material, or a JwksProvider throws a TokenValidationFailed
exception. Using key material without an algorithm also throws.
Authorization
RequireAuthorization is a PSR-15 middleware that applies an authorization predicate to the AuthenticatedUser
already verified by AuthenticationMiddleware. It must be placed in the pipeline after authentication: the middleware
reads the user from the AuthenticationMiddleware::AUTHENTICATED_USER_ATTRIBUTE request attribute. When the attribute
is absent, holds a different type, or the predicate returns false, the request is rejected with a 403 response
without invoking the next handler.
<?php declare(strict_types=1); use Skedli\HttpMiddleware\Authentication\AuthenticatedUser; use Skedli\HttpMiddleware\Authentication\AuthenticationMiddleware; use Skedli\HttpMiddleware\Authentication\RequireAuthorization; use Skedli\HttpMiddleware\Authentication\SigningAlgorithm; $authentication = AuthenticationMiddleware::create() ->withAlgorithm(algorithm: SigningAlgorithm::RS256) ->withKeyMaterial(keyMaterial: $publicKey) ->build(); $authorization = RequireAuthorization::matching( predicate: fn(AuthenticatedUser $user): bool => $user->userId() !== '', description: 'You do not have permission to access this resource.' ); # Register both in the pipeline: authentication runs first, authorization second.
Custom TokenDecoder implementations can return a richer AuthenticatedUser subtype, and the predicate parameter
may be typed against that subtype (e.g., fn(TenantAwareUser $user): bool => $user->tenantId() === $tenantId).
When authorization fails, the middleware returns a 403 Forbidden response:
{
"code": "FORBIDDEN",
"message": "You do not have permission to access this resource."
}
Health check
Two separate endpoints are provided: liveness and readiness. The liveness endpoint is queried by the orchestrator
(e.g., ECS) to decide whether to restart the task. A failure triggers an immediate restart. The readiness endpoint
is queried by the load balancer (e.g., ALB target health check) to decide routing. A failure removes the task from
the pool without restarting it. Both endpoints read the APP_NAME environment variable for the service name,
defaulting to "app" if not set.
Liveness
The LivenessHandler implements RequestHandlerInterface and provides a liveness probe endpoint. It always returns
200 OK without executing any dependency checks, making it suitable for ECS liveness probes where a failure triggers
a task restart.
<?php declare(strict_types=1); use Skedli\HttpMiddleware\HealthCheck\LivenessHandler; $handler = LivenessHandler::create()->build();
In a Slim 4 application:
<?php declare(strict_types=1); use Skedli\HttpMiddleware\HealthCheck\LivenessHandler; $app->get('/health/liveness', LivenessHandler::create()->build());
Response:
{
"status": "OK",
"service": "my-app"
}
Readiness
The ReadinessHandler implements RequestHandlerInterface and provides a readiness probe endpoint. It executes
registered dependency checks (e.g., database, cache, message broker) and returns a tri-state overall status derived
from the aggregate outcome. When a drain marker file exists, the endpoint short-circuits immediately without executing
any checks.
Default usage
At least one check must be registered. Calling build() without any check throws ReadinessMisconfigured.
<?php declare(strict_types=1); use Skedli\HttpMiddleware\HealthCheck\DoctrineHealthCheck; use Skedli\HttpMiddleware\HealthCheck\ReadinessHandler; $handler = ReadinessHandler::create() ->withCheck(check: DoctrineHealthCheck::create(connection: $connection)->build()) ->build();
In a Slim 4 application:
<?php declare(strict_types=1); use Skedli\HttpMiddleware\HealthCheck\DoctrineHealthCheck; use Skedli\HttpMiddleware\HealthCheck\ReadinessHandler; $app->get('/health/readiness', ReadinessHandler::create() ->withCheck(check: DoctrineHealthCheck::create(connection: $connection)->build()) ->build() );
Custom health checks
Implement the HealthCheck interface to verify the availability of an external dependency. Each check provides a
component() method that returns a stable generic category (e.g., "cache", "queue") and an optional name()
discriminator label. The check() method returns a HealthCheckResult.
<?php declare(strict_types=1); use Skedli\HttpMiddleware\HealthCheck\HealthCheck; use Skedli\HttpMiddleware\HealthCheck\HealthCheckResult; use Skedli\HttpMiddleware\HealthCheck\ReadinessHandler; final readonly class RedisHealthCheck implements HealthCheck { public function __construct(private Redis $redis) { } public function name(): ?string { return null; } public function check(): HealthCheckResult { try { $this->redis->ping(); return HealthCheckResult::up(); } catch (\Throwable $exception) { return HealthCheckResult::down(message: $exception->getMessage()); } } public function component(): string { return 'cache'; } } $handler = ReadinessHandler::create() ->withCheck(check: $databaseCheck) ->withCheck(check: new RedisHealthCheck(redis: $redis)) ->build();
When all checks are UP, the response is 200 OK:
{
"status": "OK",
"service": "my-app",
"checks": [
{
"status": "UP",
"critical": true,
"component": "database",
"duration_in_milliseconds": 1.23
},
{
"status": "UP",
"critical": true,
"component": "cache",
"duration_in_milliseconds": 0.45
}
]
}
When a critical check is DOWN, the response is 503 Service Unavailable:
{
"status": "UNAVAILABLE",
"service": "my-app",
"checks": [
{
"status": "DOWN",
"message": "Connection refused",
"critical": true,
"component": "database",
"duration_in_milliseconds": 102.87
},
{
"status": "UP",
"critical": true,
"component": "cache",
"duration_in_milliseconds": 0.51
}
]
}
If a check throws an unhandled exception, it is caught and reported as DOWN with the exception message.
Doctrine health check
The library provides a built-in DoctrineHealthCheck that verifies database connectivity via
Doctrine DBAL. It receives a Connection instance and
executes a SQL probe query to verify availability.
<?php declare(strict_types=1); use Skedli\HttpMiddleware\HealthCheck\DoctrineHealthCheck; $check = DoctrineHealthCheck::create(connection: $connection)->build();
By default, the check:
- Returns
"database"fromcomponent(). A fixed, stable generic category that never leaks internal schema names. - Returns
nullfromname(). No discriminator label is set unless explicitly configured. - Executes
SELECT 1as the health query. - Is marked as
critical: true.
The optional withName() builder method sets a discriminator label. Use it to distinguish multiple database
connections or, if your monitoring requires it, to explicitly expose the schema name as an opt-in decision:
<?php declare(strict_types=1); use Skedli\HttpMiddleware\HealthCheck\DoctrineHealthCheck; # Override query and criticality; no label needed for a single connection. $check = DoctrineHealthCheck::create(connection: $replicaConnection) ->withQuery(query: 'SELECT 1 FROM migrations LIMIT 1') ->withCritical(critical: false) ->build(); # Opt-in: explicitly expose the schema name as the discriminator label. $check = DoctrineHealthCheck::create(connection: $connection) ->withName(name: $connection->getDatabase() ?? 'primary') ->build();
Non-critical checks
Checks default to critical: true. A non-critical check that is DOWN produces a DEGRADED
overall status and HTTP 200. This is useful for optional dependencies like caches whose unavailability does not stop the
service.
<?php declare(strict_types=1); use Skedli\HttpMiddleware\HealthCheck\HealthCheck; use Skedli\HttpMiddleware\HealthCheck\HealthCheckResult; final readonly class CacheHealthCheck implements HealthCheck { public function __construct(private Redis $redis) { } public function name(): ?string { return null; } public function check(): HealthCheckResult { try { $this->redis->ping(); return HealthCheckResult::up(critical: false); } catch (\Throwable $exception) { return HealthCheckResult::down(message: $exception->getMessage(), critical: false); } } public function component(): string { return 'cache'; } }
Multiple instances of the same component
When the same component category appears more than once (e.g., a primary and a read replica), use withName() to
add a discriminator label. The component field remains the generic category; name distinguishes the instances.
<?php declare(strict_types=1); use Skedli\HttpMiddleware\HealthCheck\DoctrineHealthCheck; use Skedli\HttpMiddleware\HealthCheck\ReadinessHandler; $handler = ReadinessHandler::create() ->withCheck(check: DoctrineHealthCheck::create(connection: $primaryConnection)->withName(name: 'primary')->build()) ->withCheck( check: DoctrineHealthCheck::create(connection: $replicaConnection) ->withName(name: 'replica') ->withCritical(critical: false) ->build() ) ->build();
When both are UP, the response is:
{
"status": "OK",
"service": "billing",
"checks": [
{
"name": "primary",
"status": "UP",
"critical": true,
"component": "database",
"duration_in_milliseconds": 1.23
},
{
"name": "replica",
"status": "UP",
"critical": false,
"component": "database",
"duration_in_milliseconds": 0.45
}
]
}
Degraded readiness
When all critical checks are UP but at least one non-critical check is DOWN, the overall status is DEGRADED. The
HTTP status code is still 200 OK. The service is operational and accepts traffic, but one optional dependency is
unavailable.
{
"status": "DEGRADED",
"service": "identity",
"checks": [
{
"status": "UP",
"critical": true,
"component": "database",
"duration_in_milliseconds": 1.23
},
{
"status": "DOWN",
"message": "Connection refused",
"critical": false,
"component": "cache",
"duration_in_milliseconds": 102.87
}
]
}
Drain-aware readiness
Use withDrainMarker(path: ...) to configure a sentinel file path. When the file exists, ReadinessHandler returns
503 Service Unavailable with {"reason":"draining"} immediately, without executing any checks. This short-circuits
dependency calls during graceful shutdown, saving syscalls while the task drains in-flight requests.
<?php declare(strict_types=1); use Skedli\HttpMiddleware\HealthCheck\DoctrineHealthCheck; use Skedli\HttpMiddleware\HealthCheck\ReadinessHandler; $handler = ReadinessHandler::create() ->withCheck(check: DoctrineHealthCheck::create(connection: $connection)->build()) ->withDrainMarker(path: '/tmp/draining') ->build();
When draining, the response is:
{
"status": "UNAVAILABLE",
"service": "my-app",
"reason": "draining"
}
Creating the drain marker is the responsibility of the consuming repository, not this library. The container
entrypoint script must trap SIGTERM, create the sentinel file, then forward the signal to PHP-FPM and wait:
#!/bin/sh set -e DRAIN_FILE="/tmp/draining" PHP_FPM_PID="" cleanup() { touch "$DRAIN_FILE" if [ -n "$PHP_FPM_PID" ]; then kill -TERM "$PHP_FPM_PID" wait "$PHP_FPM_PID" fi rm -f "$DRAIN_FILE" } trap cleanup TERM php-fpm --nodaemonize & PHP_FPM_PID=$! wait "$PHP_FPM_PID"
Response format
The overall status is tri-state:
Overall status |
Condition | HTTP |
|---|---|---|
OK |
All checks UP | 200 OK |
DEGRADED |
All critical UP, at least one non-critical DOWN | 200 OK |
UNAVAILABLE |
At least one critical DOWN, or drain active | 503 Service Unavailable |
Each entry in the checks array contains:
| Field | Type | Present | Description |
|---|---|---|---|
name |
string | When set | Discriminator label distinguishing instances of the same component. |
status |
string | Always | UP or DOWN. |
message |
string | When DOWN | Present only when the check is DOWN with a reason. |
critical |
boolean | Always | Whether this check affects the overall HTTP status code. |
component |
string | Always | Generic component category (e.g., "database", "cache"). Never leaks internal names. |
duration_in_milliseconds |
float | Always | Time taken to execute the check. |
Idempotency
The IdempotencyMiddleware ensures that mutating HTTP requests (POST, PUT, PATCH, DELETE) are processed
exactly once. When a client sends a request with an Idempotency-Key header, the middleware hashes the request body
(SHA-256) and stores the first successful response. Subsequent requests with the same key replay the stored response,
even if the downstream handler would be invoked again.
If the same key is reused with a different request payload, the middleware returns 409 Conflict without forwarding
the request.
Default usage
Configure the middleware with an IdempotencyReader and IdempotencyWriter implementation. The
DoctrineIdempotencyStore adapter is provided for
Doctrine DBAL:
<?php declare(strict_types=1); use Skedli\HttpMiddleware\Idempotency\DoctrineIdempotencyStore; use Skedli\HttpMiddleware\Idempotency\IdempotencyMiddleware; $store = DoctrineIdempotencyStore::create(connection: $connection)->build(); $middleware = IdempotencyMiddleware::create() ->withReader(reader: $store) ->withWriter(writer: $store) ->build();
In a Slim 4 application:
<?php declare(strict_types=1); use Skedli\HttpMiddleware\Idempotency\DoctrineIdempotencyStore; use Skedli\HttpMiddleware\Idempotency\IdempotencyMiddleware; $store = DoctrineIdempotencyStore::create(connection: $connection)->build(); $app->add( IdempotencyMiddleware::create() ->withReader(reader: $store) ->withWriter(writer: $store) ->build() );
Storing responses with Doctrine DBAL
The DoctrineIdempotencyStore persists idempotency entries in a relational table via
Doctrine DBAL. Build it with the
DoctrineIdempotencyStore::create() builder. The table name defaults to idempotency_keys:
<?php declare(strict_types=1); use Skedli\HttpMiddleware\Idempotency\DoctrineIdempotencyStore; $store = DoctrineIdempotencyStore::create(connection: $connection) ->withTable('idempotency_keys') ->build();
The table must exist before the middleware processes any request. Create it with the following MySQL schema:
CREATE TABLE idempotency_keys ( idempotency_key VARCHAR(255) NOT NULL COMMENT 'The idempotency key supplied by the caller (e.g. 550e8400-e29b-41d4-a716-446655440000).', namespace VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'The scope namespace resolved by the scope provider (e.g. tenant ID, user ID, or empty string for global scope).', ttl_seconds INT UNSIGNED NOT NULL COMMENT 'TTL applied when the row was persisted.', request_hash CHAR(64) NOT NULL COMMENT 'The SHA-256 hash of the request body at the time of the first successful request.', response_body LONGBLOB NOT NULL COMMENT 'The raw response body bytes of the first successful request.', response_status SMALLINT NOT NULL COMMENT 'The HTTP status code of the cached response.', response_content_type VARCHAR(255) NOT NULL COMMENT 'The Content-Type header of the cached response.', expires_at DATETIME NOT NULL COMMENT 'UTC timestamp when the idempotency entry expires.', created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT 'The UTC date and time when the record was created in ISO 8601 format (e.g. 2026-02-13T08:49:44.931408+00:00).' );
Custom scope provider
By default, all requests share a single global namespace. Implement IdempotencyScopeProvider to isolate idempotency
keys per tenant, user, or any other scope:
<?php declare(strict_types=1); use Psr\Http\Message\ServerRequestInterface; use Skedli\HttpMiddleware\Idempotency\IdempotencyScopeProvider; final readonly class TenantScopeProvider implements IdempotencyScopeProvider { public function resolve(ServerRequestInterface $request): string { # @var \Skedli\HttpMiddleware\Authentication\AuthenticatedUser $user $user = $request->getAttribute('authenticatedUser'); return $user->tenantId(); } }
Then configure the middleware with the custom provider:
<?php declare(strict_types=1); use Skedli\HttpMiddleware\Idempotency\IdempotencyMiddleware; $middleware = IdempotencyMiddleware::create() ->withReader(reader: $store) ->withWriter(writer: $store) ->withScopeProvider(scopeProvider: new TenantScopeProvider()) ->build();
Builder options
| Method | Default | Description |
|---|---|---|
withReader(reader: ...) |
required | The IdempotencyReader implementation to use. |
withWriter(writer: ...) |
required | The IdempotencyWriter implementation to use. |
withHeaderName(headerName: ...) |
'Idempotency-Key' |
HTTP header name to read the idempotency key from. |
withTtlSeconds(ttlSeconds: ...) |
86400 (24 hours) |
Time-to-live in seconds for each stored response. |
withMethods(methods: ...) |
['POST','PUT','PATCH','DELETE'] |
HTTP methods subject to idempotency checks. |
withScopeProvider(scopeProvider: ...) |
global scope (empty namespace) | Provider that resolves the namespace for each request. |
Custom store
Implement IdempotencyReader and IdempotencyWriter to use any persistence backend. The save method must be
race-safe:
<?php declare(strict_types=1); use Skedli\HttpMiddleware\Idempotency\IdempotencyLookup; use Skedli\HttpMiddleware\Idempotency\IdempotencyReader; use Skedli\HttpMiddleware\Idempotency\IdempotencyWriter; use Skedli\HttpMiddleware\Idempotency\IdempotentResponse; final class RedisIdempotencyStore implements IdempotencyReader, IdempotencyWriter { public function __construct(private \Redis $redis) { } public function find(IdempotencyLookup $lookup): ?IdempotentResponse { $raw = $this->redis->get("{$lookup->namespace()}:{$lookup->key()}"); if ($raw === false) { return null; } $data = json_decode($raw, true); return new RedisIdempotentResponse( key: $data['key'], namespace: $data['namespace'], statusCode: $data['status_code'], ttlSeconds: $data['ttl_seconds'], requestHash: $data['request_hash'], responseBody: $data['response_body'], responseContentType: $data['response_content_type'] ); } public function save(IdempotentResponse $response): IdempotentResponse { $raw = json_encode([ 'key' => $response->key(), 'namespace' => $response->namespace(), 'status_code' => $response->statusCode(), 'ttl_seconds' => $response->ttlSeconds(), 'request_hash' => $response->requestHash(), 'response_body' => $response->responseBody(), 'response_content_type' => $response->responseContentType() ]); $this->redis->setex("{$response->namespace()}:{$response->key()}", $response->ttlSeconds(), $raw); return $response; } }
Race-condition guarantees
DoctrineIdempotencyStore::save() is race-safe. When two concurrent requests arrive with the same key simultaneously,
one
insert wins and the other encounters a unique constraint violation. The losing thread re-reads the row written by the
winner and returns it as though it had been found on the initial lookup.
If the row disappears between the violation and the re-read (expired in a very narrow window), the original entry is returned as a fallback. In all cases the response is consistent: identical payloads produce the same outcome.
Conflict response
When the same idempotency key is reused with a different payload hash, the middleware returns 409 Conflict without
forwarding the request to the handler:
{
"code": "IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_PAYLOAD",
"message": "The idempotency key was already used with a different request payload."
}
No existing entry is overwritten. The original stored response is preserved.
License
HTTP Middleware is licensed under MIT.