skedli/http-middleware

PSR-15 middleware components for correlation ID propagation, structured logging, error handling, JWT authentication, and idempotency.

Maintainers

Package info

github.com/skedli/http-middleware

pkg:composer/skedli/http-middleware

Statistics

Installs: 234

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0


README

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-Id header.

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" from component(). A fixed, stable generic category that never leaks internal schema names.
  • Returns null from name(). No discriminator label is set unless explicitly configured.
  • Executes SELECT 1 as 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.