duyler / openapi
Duyler openapi validator
Requires
- php: ^8.4
- psr/cache: ^3.0
- psr/event-dispatcher: ^1.0
- psr/http-message: ^2.0
- psr/log: ^3.0
- symfony/yaml: ^7.0 || ^8.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.80
- giorgiosironi/eris: ^1.0
- guzzlehttp/psr7: ^2.6
- infection/infection: ^0.32
- laminas/laminas-diactoros: ^3.3
- nyholm/psr7: ^1.8
- phpunit/phpunit: ^13.0
- rector/rector: ^2.0
- vimeo/psalm: ^6.10
Suggests
- ext-intl: Required for full SMTPUTF8 (RFC 6531) and IDNA hostname validation; permissive regex fallback when absent
- nyholm/psr7: PSR-7 implementation used in tests; use any PSR-7 implementation in production
- psr/http-server-middleware: Required if you implement the PSR-15 middleware example from README
- symfony/cache: PSR-6 cache implementation for SchemaCache
- symfony/event-dispatcher: PSR-14 event dispatcher implementation
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-22 06:01:45 UTC
README
OpenAPI 3.2 validator for PHP 8.4+
Features
- OpenAPI 3.2 Support - JSON Schema draft 2020-12 validation with known limitations (see Limitations)
- JSON Schema Validation - Full JSON Schema draft 2020-12 validation with 30 validators
- PSR-7 Integration - PSR-7 HTTP message validation (works with any PSR-7 implementation)
- Request Validation - Validate path parameters, query parameters, headers, cookies, and request body
- Response Validation - Validate status codes, headers, and response bodies
- Multiple Content Types - Support for JSON, form-data, multipart, text, and XML
- Built-in Format Validators - 26 built-in validators (email, UUID, date-time, URI, IPv4/IPv6, int32, int64, iri, uri-template, regex, etc.)
- Custom Format Validators - Easily register custom format validators
- Discriminator Support - Full support for polymorphic schemas with discriminators
- Type Coercion - Optional automatic type conversion
- PSR-6 Caching - Cache parsed OpenAPI documents for better performance
- PSR-14 Events - Subscribe to validation lifecycle events
- Error Formatting - Multiple error formatters (simple, detailed, JSON)
- Webhooks Support - Validate incoming webhook requests
- Streaming Validation - Validate NDJSON, SSE, and JSON Text Sequences responses
- Schema Registry - Manage multiple schema versions
Installation
composer require duyler/openapi
Quick Start
Basic Usage
use Duyler\OpenApi\Builder\OpenApiValidatorBuilder; $validator = OpenApiValidatorBuilder::create() ->fromYamlFile('openapi.yaml') ->build(); // Validate request $operation = $validator->validateRequest($request); // Validate response $validator->validateResponse($response, $operation);
Using the Validator Interface
The builder returns an OpenApiValidatorInterface instance. Use this interface for type-hinting in your services:
use Duyler\OpenApi\Builder\OpenApiValidatorInterface; class UserService { public function __construct( private readonly OpenApiValidatorInterface $validator, ) {} public function handleRequest(ServerRequestInterface $request): void { $operation = $this->validator->validateRequest($request); // $operation->path matched template path, e.g. "/users/{id}" // $operation->method matched HTTP method // $operation->operationId operationId from the spec (nullable) // $operation->pathParameters resolved values, e.g. ['id' => '42'] // $operation->schemaOperation Schema\Model\Operation reference (nullable) $userId = $operation->pathParameters['id'] ?? null; // ... } }
The returned Operation is a final readonly value object implementing Stringable: (string) $operation yields the template form, e.g. 'GET /users/{id}' (the path is the route template, not the concrete request URI), and Operation::countPlaceholders(): int returns the number of {...} placeholders.
The interface exposes the following methods:
| Method | Description |
|---|---|
validateRequest(ServerRequestInterface $request): Operation |
Validate and return matched operation |
validateResponse(ResponseInterface $response, Operation $operation): void |
Validate response against operation |
validateSchema(mixed $data, string $schemaRef): void |
Validate data against a schema reference |
validateWebhook(ServerRequestInterface $request, string $name): Operation |
Validate webhook request |
validateCallback(ServerRequestInterface $request, string $name): Operation |
Validate callback request |
getDocument(): OpenApiDocument |
Returns the loaded OpenAPI document for introspection or routing maps (available after build()) |
resolveLink(string $linkName, array $responseData): ResolvedLink |
Resolve link parameters from response data (response body only) |
resolveLinkWithContext(string $linkName, LinkContext $context): ResolvedLink |
Resolve link parameters with full Runtime Expression support |
reset(): void |
Reset validator state for reuse |
getDocument() returns an immutable OpenApiDocument value object implementing JsonSerializable (use json_encode() for the canonical JSON). Its top-level fields mirror the OpenAPI 3.2 document structure (info, servers, paths, webhooks, components, security, tags, externalDocs), making it useful for routing maps and SchemaRegistry registration.
Usage
Loading OpenAPI Specifications
use Duyler\OpenApi\Builder\OpenApiValidatorBuilder; // From YAML file $validator = OpenApiValidatorBuilder::create() ->fromYamlFile('openapi.yaml') ->build(); // From JSON file $validator = OpenApiValidatorBuilder::create() ->fromJsonFile('openapi.json') ->build(); // From YAML string $yaml = file_get_contents('openapi.yaml'); $validator = OpenApiValidatorBuilder::create() ->fromYamlString($yaml) ->build(); // From JSON string $json = file_get_contents('openapi.json'); $validator = OpenApiValidatorBuilder::create() ->fromJsonString($json) ->build();
YAML Anchor and Alias Caps
Before parsing, the validator scans the raw YAML and enforces caps on anchors (&name, max 100), aliases (*name, max 1000), and alias chain depth (max 10). This blocks "billion laughs" expansion bombs before the YAML parser materialises them; exceeding any cap throws SpecTooLargeException. The scanner is conservative and may count a literal &Word sequence inside a double-quoted string as an anchor; treat such specs as trusted or pre-process them before parsing.
External $ref Resolution
The validator supports external $ref references for file:// URIs and relative-path refs. The builtin resolver loads the referenced YAML/JSON file, follows an optional JSON Pointer fragment (e.g. components/user.yaml#/UserSchema), and returns the referenced schema:
# openapi.yaml components: schemas: User: $ref: 'components/user.yaml#/UserSchema'
Only file:// URIs and scheme-less relative paths are allowed; every other scheme — http://, data://, phar://, or any other PHP stream wrapper — is rejected with ExternalRefSecurityException, surfaced as UnresolvableRefException. To enable other schemes, inject a custom ExternalRefResolverInterface implementation:
use Duyler\OpenApi\Schema\Model\Schema; use Duyler\OpenApi\Validator\Schema\ExternalRefResolverInterface; use Duyler\OpenApi\Validator\Schema\RefResolver; final class MyHttpExternalRefResolver implements ExternalRefResolverInterface { public function resolve(string $ref): Schema { // fetch $ref over HTTP, return Schema } } $refResolver = new RefResolver(new MyHttpExternalRefResolver());
To defend against ../../../etc/passwd style path traversal and symlink escapes, the builtin resolver accepts an optional allowedRoot: both the requested path and the root are resolved via realpath(), and any reference outside the root is refused:
use Duyler\OpenApi\Validator\Schema\FileExternalRefResolver; $resolver = new FileExternalRefResolver(allowedRoot: '/var/specs');
When the spec is loaded with fromYamlFile() or fromJsonFile(), the builder automatically derives allowedRoot from the spec file's directory. Override it with withExternalRefAllowedRoot() when refs must reach outside the spec directory (the path must exist, otherwise the builder throws BuilderException):
$validator = OpenApiValidatorBuilder::create() ->fromYamlFile('/var/specs/openapi.yaml') ->withExternalRefAllowedRoot('/var/shared-components') ->build();
Specs loaded from a string (fromYamlString() / fromJsonString()) have no directory to derive a root from, so they fail closed at build() time when the spec contains an external $ref and no explicit root was set. External ref files are read with a default size cap of 10 MB (withExternalRefMaxBytes()); larger files throw ExternalRefTooLargeException, and non-regular files (device files, FIFOs, sockets) are rejected with ExternalRefSecurityException.
PSR-7 Integration
The validator works with any PSR-7 implementation. The examples in this README use nyholm/psr7 (installed as a dev dependency); substitute your preferred implementation (Guzzle PSR-7, Laminas Diactoros) in production:
use Nyholm\Psr7\Factory\Psr17Factory; use Duyler\OpenApi\Builder\OpenApiValidatorBuilder; $factory = new Psr17Factory(); $request = $factory->createServerRequest('POST', '/users') ->withHeader('Content-Type', 'application/json') ->withBody($factory->createStream('{"name": "John"}')); $validator = OpenApiValidatorBuilder::create() ->fromYamlFile('openapi.yaml') ->build(); $operation = $validator->validateRequest($request); // $operation contains the matched path and method
Webhooks
Validate webhook requests using the builder API:
use Duyler\OpenApi\Builder\OpenApiValidatorBuilder; $validator = OpenApiValidatorBuilder::create() ->fromYamlFile('openapi.yaml') ->build(); $operation = $validator->validateWebhook($request, 'payment.webhook');
Callbacks
Validate callback requests using the builder API:
use Duyler\OpenApi\Builder\OpenApiValidatorBuilder; $validator = OpenApiValidatorBuilder::create() ->fromYamlFile('openapi.yaml') ->build(); $operation = $validator->validateCallback($request, 'myCallback');
Strict fail-closed default: callback runtime expressions like
{$request.body#/callback_url}reference the original triggering request body and cannot be resolved by the validator. The builder therefore fails closed by default: any callback expression that contains a runtime template throwsUnresolvableCallbackPathExceptioninstead of being treated as a wildcard that accepts any URL. This prevents attacker-controlled runtime templates from bypassing path validation.
To opt back into the legacy wildcard behaviour, call disableStrictCallbackRuntimeTemplate():
$validator = OpenApiValidatorBuilder::create() ->fromYamlFile('openapi.yaml') ->disableStrictCallbackRuntimeTemplate() ->build();
SECURITY WARNING:
disableStrictCallbackRuntimeTemplate()disables the protection against SSRF via attacker-controlled callback URLs: declared security checks still pass against an arbitrary URL when the runtime template is unresolvable. Use it only when callback URLs are validated at the application level (an allowlist of permitted outbound hosts, signed callback URLs, or destination validation before any outbound request is issued).
Link Resolution
Resolve OpenAPI Link parameters from response data. Both methods return a ResolvedLink DTO exposing resolved parameters, requestBody, and the optional server override declared by the link.
use Duyler\OpenApi\Validator\Link\LinkContext; // Simple resolution (response body only) $result = $validator->resolveLink('GetUserById', ['id' => 42, 'name' => 'John']); $result->parameters; // array<string, mixed> $result->requestBody; // mixed $result->server; // Server|null // Full resolution with Runtime Expression support $context = new LinkContext( body: ['id' => 42, 'name' => 'John'], headers: ['X-Request-Id' => 'abc123'], queryParams: ['page' => 1], url: 'https://api.example.com/users/42', method: 'GET', statusCode: 200, pathParams: ['userId' => 42], requestHeaders: ['X-Request-Id' => 'req-789'], requestBody: ['extra' => 'payload'], ); $result = $validator->resolveLinkWithContext('GetUserById', $context);
resolveLink() populates only the response body context, so it can resolve $response.body expressions. Use resolveLinkWithContext() to supply the full request and response state and unlock all OpenAPI 3.2 §6.19.2 runtime expressions:
| Expression | Resolves from LinkContext |
|---|---|
$url |
url |
$method |
method |
$statusCode |
statusCode |
$request.path.{name} |
pathParams[{name}] |
$request.query.{name} |
queryParams[{name}] |
$request.header.{name} |
requestHeaders[{name}] (case-insensitive, RFC 9110) |
$request.body |
requestBody (whole value) |
$request.body#/{pointer} |
requestBody navigated by JSON Pointer |
$response.body |
body (whole value) |
$response.body#/{pointer} |
body navigated by JSON Pointer |
$response.header |
headers (whole map) |
| `$response.header[.{name} | #/{name}]` |
$response.query |
queryParams (whole map) |
| `$response.query[.{name} | #/{name}]` |
Unsupported expressions are returned as the literal string so callers can distinguish them from values that legitimately resolve to null.
Advanced Usage
Custom Format Validators
Register custom format validators for domain-specific validation:
use Duyler\OpenApi\Validator\Format\FormatValidatorInterface; use Duyler\OpenApi\Validator\Exception\InvalidFormatException; // Create a custom validator class PhoneNumberValidator implements FormatValidatorInterface { public function validate(mixed $data): void { if (!is_string($data) || !preg_match('/^\+?[1-9]\d{1,14}$/', $data)) { throw new InvalidFormatException( 'phone', $data, 'Value must be a valid E.164 phone number' ); } } } // Register with the builder $validator = OpenApiValidatorBuilder::create() ->fromYamlFile('openapi.yaml') ->withFormat('string', 'phone', new PhoneNumberValidator()) ->build();
Formats registered via withFormat() override built-in validators whenever both target the same (type, format) pair — the same mechanism is used to replace built-ins (see Overriding Built-in Validators).
Type Coercion
Enable automatic type conversion for query parameters and request body:
$validator = OpenApiValidatorBuilder::create() ->fromYamlFile('openapi.yaml') ->enableCoercion() // Convert string "123" to integer 123 ->build();
Coercion is strict by default and applies to request parameters (query, path, headers, cookies) and to JSON body values alike: strings are converted to their declared types, and non-string scalars produced by json_decode(..., true) are normalised too (bool true → int 1 for type: integer, int 42 → "42" for type: string). Union types such as type: [integer, string] try each branch in order and use the first successful coercion. disableStrictCoercion() restores the legacy lax casting behaviour; overflow and precision-loss guards remain active.
Discriminator Validation
Validate polymorphic schemas with discriminators:
$yaml = <<<YAML openapi: 3.2.0 components: schemas: Pet: type: object required: [petType] discriminator: propertyName: petType mapping: cat: '#/components/schemas/Cat' dog: '#/components/schemas/Dog' oneOf: - $ref: '#/components/schemas/Cat' - $ref: '#/components/schemas/Dog' Cat: type: object required: [petType, name] properties: petType: { type: string, enum: [cat] } name: { type: string } Dog: type: object required: [petType, name, breed] properties: petType: { type: string, enum: [dog] } name: { type: string } breed: { type: string } YAML; $validator = OpenApiValidatorBuilder::create() ->fromYamlString($yaml) ->build(); // Validates against Cat schema $data = ['petType' => 'cat', 'name' => 'Fluffy']; $validator->validateSchema($data, '#/components/schemas/Pet');
Candidate enumeration follows JSON Schema 2020-12: when a schema declares more than one composition keyword (oneOf, anyOf, allOf), candidates are collected from all of them, and a non-matching nested candidate does not abort the search — remaining candidates are tried before the validator gives up. The OpenAPI defaultMapping keyword is honoured as the final fallback for any unresolved discriminator value, regardless of whether propertyName is set.
Event-Driven Validation
Subscribe to validation lifecycle events via PSR-14:
use Duyler\OpenApi\Event\ArrayDispatcher; use Duyler\OpenApi\Event\ValidationStartedEvent; use Duyler\OpenApi\Event\ValidationErrorEvent; use Duyler\OpenApi\Builder\OpenApiValidatorBuilder; $dispatcher = new ArrayDispatcher([ ValidationStartedEvent::class => [ function (ValidationStartedEvent $event) { printf("Validation started: %s %s\n", $event->method, $event->path); }, ], ValidationErrorEvent::class => [ function (ValidationErrorEvent $event) { error_log(sprintf( "Validation failed for %s %s: %s", $event->method, $event->path, $event->exception->getMessage() )); }, ], ]); $validator = OpenApiValidatorBuilder::create() ->fromYamlFile('openapi.yaml') ->withEventDispatcher($dispatcher) ->build();
Available events:
| Event | Description |
|---|---|
ValidationStartedEvent |
Dispatched before validation begins |
ValidationFinishedEvent |
Dispatched after validation completes |
ValidationErrorEvent |
Dispatched when validation fails |
ValidationWarningEvent |
Dispatched for non-fatal validation warnings |
Listeners can also be registered after construction. ArrayDispatcher is immutable: listen() returns a new instance, so reassign the result — chaining without reassignment silently discards the added listeners:
$dispatcher = $dispatcher ->listen(ValidationStartedEvent::class, $myStartedListener) ->listen(ValidationErrorEvent::class, $myErrorListener);
Schema Registry
Manage multiple API versions:
use Duyler\OpenApi\Builder\OpenApiValidatorBuilder; use Duyler\OpenApi\Registry\SchemaRegistry; $validatorV1 = OpenApiValidatorBuilder::create() ->fromYamlFile('api-v1.yaml') ->build(); $validatorV2 = OpenApiValidatorBuilder::create() ->fromYamlFile('api-v2.yaml') ->build(); // The registry is immutable: register() returns a new instance and throws // SchemaAlreadyRegisteredException on a duplicate name+version pair. // registerOrReplace() is the explicit-overwrite counterpart (e.g. hot reload). $registry = (new SchemaRegistry()) ->register('api', '1.0.0', $validatorV1->getDocument()) ->register('api', '2.0.0', $validatorV2->getDocument()); // get() returns null for a missing name or version; getOrFail() throws // VersionNotFoundException. A call without a version returns the latest // version (sorted by semver). $schema = $registry->get('api', '1.0.0'); $latest = $registry->get('api'); // Introspection: getVersions(), has(), getNames(), and the count* helpers. $versions = $registry->getVersions('api'); // ['1.0.0', '2.0.0']
Validator Pool
The pool reuses validator instances through an LRU cache with a default capacity of 128 entries; the least recently used entry is evicted when the pool is full. It is safe to share in prefork models (PHP-FPM, RoadRunner, FrankenPHP non-threaded). Under Swoole coroutines or threaded FrankenPHP workers, concurrent getOrCreate() calls race on the check-then-act sequence: pass a lock (any object with lock() and unlock() methods, such as Swoole\Lock) or give each worker its own pool. The factory callable passed to getOrCreate() must be non-blocking (no I/O) and non-recursive — the lock is held for its entire duration, so suspending or recursing inside it deadlocks.
use Duyler\OpenApi\Validator\ValidatorPool; $pool = new ValidatorPool(); // default: 128 entries $pool = new ValidatorPool(maxSize: 64); // custom capacity // Swoole / threaded runtimes: pass a lock to serialize access $pool = new ValidatorPool(maxSize: 128, lock: new \Swoole\Lock()); // Or use the named constructor to make the concurrency contract explicit $pool = ValidatorPool::forCoroutineRuntime(new \Swoole\Lock(), maxSize: 128); // Validators are automatically reused and evicted when capacity is exceeded $validator = OpenApiValidatorBuilder::create() ->fromYamlFile('openapi.yaml') ->withValidatorPool($pool) ->build();
Configuration Options
Builder Methods
| Method | Description | Default |
|---|---|---|
create() |
Static factory entry point; returns a fresh builder instance. | - |
build() |
Terminal method — materialises the spec, wires dependencies, and returns an OpenApiValidatorInterface. Call exactly once per builder instance. |
- |
fromYamlFile(string $path) |
Load spec from YAML file | - |
fromJsonFile(string $path) |
Load spec from JSON file | - |
fromYamlString(string $content) |
Load spec from YAML string | - |
fromJsonString(string $content) |
Load spec from JSON string | - |
withCache(SchemaCache $cache) |
Enable PSR-6 caching | null |
withEventDispatcher(EventDispatcherInterface $dispatcher) |
Set PSR-14 event dispatcher | null |
withErrorFormatter(ErrorFormatterInterface $formatter) |
Set error formatter | SimpleFormatter |
withDetailedErrors(bool $includeSensitive) |
Use DetailedFormatter; true includes sensitive values in output |
DetailedFormatter (sensitive values omitted) |
withSecurityVerboseLogging(LoggerInterface $logger) |
Debug-level logging of security validation details and external ref paths | null |
withFormat(string $type, string $format, FormatValidatorInterface $validator) |
Register custom format | - |
withValidatorPool(ValidatorPool $pool) |
Set custom validator pool | new ValidatorPool() |
withLogger(LoggerInterface $logger) |
Set PSR-3 logger | null |
withEmptyArrayStrategy(EmptyArrayStrategy $strategy) |
Set empty array validation strategy | AllowBoth |
enableCoercion() |
Enable type coercion | false |
disableStrictCoercion() |
Restore legacy lax type coercion (loose boolean/integer/number casting) | true (strict default) |
enableNullableAsType() |
Enable nullable validation | true |
disableNullableAsType() |
Disable nullable validation | false |
enableSecurityValidation() |
Enable security scheme validation for requests | false |
enableStrictFormats() |
Reject unknown format values instead of skipping | false |
enableReportDeprecated() |
Log deprecated schema elements via PSR-3 logger | true |
enableServerPathResolution() |
Strip server base path from request path before matching | false |
disableStrictCallbackRuntimeTemplate() |
Opt out of strict callback runtime template resolution. SECURITY WARNING: unresolvable callback expressions like {$request.body#/callback_url} are treated as wildcards that accept any URL, enabling SSRF via attacker-controlled callback URLs. Use only when callback URLs are validated at the application level. |
false (opt-in legacy mode) |
withExternalRefAllowedRoot(string $path) |
Directory that external file:// $ref references must stay inside |
null (auto-derived from spec path for file-loaded specs) |
withExternalRefMaxBytes(int $bytes) |
Set max external ref file size | 10485760 (10 MB) |
withMaxSpecSize(int $bytes) |
Max allowed spec payload size in bytes (YAML and JSON) | 1048576 (1 MB) |
withMaxSpecDepth(int $depth) |
Max allowed spec nesting depth | 100 |
withMaxJsonBodySize(int $bytes) |
Max size for non-multipart request and response bodies (JSON, XML, text); larger bodies are rejected before being fully materialised | 10485760 (10 MB) |
withMaxMultipartBodySize(int $bytes) |
Max size for multipart request and response bodies (typically larger uploads) | 52428800 (50 MB) |
withMaxRegexBacktracks(int $maxBacktracks) |
Defensive pcre.backtrack_limit applied to schema pattern evaluation (ReDoS defence) |
10_000 |
withMaxStreamingRecords(int $max) |
Max number of records accepted from a single streaming response | 100000 |
enableStrictStreaming() |
Malformed stream records throw MalformedStreamRecordException instead of being logged and skipped |
false |
disableStrictStreaming() |
Restore default fail-open streaming behaviour (malformed records logged and skipped) | false |
Deprecated reporting is enabled by default; without a PSR-3 logger the warnings go to NullLogger and produce no output. To suppress them, simply omit the logger (there is no disableReportDeprecated() method).
EmptyArrayStrategy
When an OpenAPI schema defines a property as type: array and the value is an empty array [], JSON does not distinguish between an empty array and an empty object. This strategy controls how the validator treats empty arrays:
| Strategy | Behavior |
|---|---|
AllowBoth (default) |
Empty arrays pass validation for both array and object types |
PreferArray |
Empty arrays are treated as arrays, not objects |
PreferObject |
Empty arrays are treated as objects, not arrays |
Reject |
Empty arrays are rejected for both array and object types |
use Duyler\OpenApi\Validator\EmptyArrayStrategy; $validator = OpenApiValidatorBuilder::create() ->fromYamlFile('openapi.yaml') ->withEmptyArrayStrategy(EmptyArrayStrategy::PreferArray) ->build();
Example Configuration
use Duyler\OpenApi\Builder\OpenApiValidatorBuilder; use Symfony\Component\Cache\Adapter\FilesystemAdapter; use Duyler\OpenApi\Cache\SchemaCache; use Duyler\OpenApi\Validator\Error\Formatter\DetailedFormatter; $cachePool = new FilesystemAdapter(); $schemaCache = new SchemaCache($cachePool, 3600); $validator = OpenApiValidatorBuilder::create() ->fromYamlFile('openapi.yaml') ->withCache($schemaCache) // Cache parsed specs ->withErrorFormatter(new DetailedFormatter()) // Detailed errors ->enableCoercion() // Auto type conversion ->build();
PSR-15 Middleware
Note: The middleware below is an example snippet, not a class shipped with this package. Copy it into your project and adapt it to your framework. The PSR-15 interfaces (
psr/http-server-middleware) are required by your framework, not by this library.
Wrap the validator in a PSR-15 middleware to validate incoming requests before they reach your handlers. On validation failure, the middleware returns a 400 Bad Request response with error details.
use Duyler\OpenApi\Builder\OpenApiValidatorBuilder; use Duyler\OpenApi\Builder\OpenApiValidatorInterface; use Duyler\OpenApi\Validator\Exception\ValidationException; use Nyholm\Psr7\Response; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; use Throwable; final class ValidationMiddleware implements MiddlewareInterface { public function __construct( private readonly OpenApiValidatorInterface $validator, ) {} public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { try { $operation = $this->validator->validateRequest($request); } catch (ValidationException $e) { return new Response( status: 400, headers: ['Content-Type' => 'application/json'], body: json_encode([ 'error' => 'Validation failed', 'details' => array_map(fn ($error) => [ 'path' => $error->dataPath(), 'message' => $error->message(), ], $e->getErrors()), ], JSON_PRETTY_PRINT), ); } catch (Throwable $e) { return new Response( status: 400, headers: ['Content-Type' => 'application/json'], body: json_encode(['error' => 'Internal validation error'], JSON_PRETTY_PRINT), ); } return $handler->handle($request->withAttribute('operation', $operation)); } }
Register the middleware with your framework's middleware pipeline:
$validator = OpenApiValidatorBuilder::create() ->fromYamlFile('openapi.yaml') ->build(); $middleware = new ValidationMiddleware($validator); // Register with any PSR-15 compatible framework or dispatcher // Example with Mezzio: // $pipeline->pipe(new ValidationMiddleware($validator));
Supported JSON Schema Keywords
The validator supports the following JSON Schema draft 2020-12 keywords:
Type Validation
type- String, number, integer, boolean, array, object, nullenum- Enumerated valuesconst- Constant valuenullable- Allows null values (default: enabled)
properties: username: type: string nullable: true # Allows null values
Nullable validation is enabled by default; call disableNullableAsType() to treat nullable: true as not allowing null values.
String Validation
minLength/maxLength- String length constraintspattern- Regular expression patternformat- Format validation (email, uri, uuid, date-time, etc.)
String length is counted in UTF-16 code units: supplementary characters count as 2 (surrogate pairs), matching JavaScript's String.prototype.length. This can differ from code-point counting (used by ajv by default) for schemas whose length thresholds fall around supplementary characters.
Pattern Validation
All regular expressions in schemas are validated at parse time; an invalid pattern throws InvalidPatternException. Patterns are honoured on the pattern, patternProperties, and propertyNames keywords. Delimiters are added automatically, so both ^test$ and /^test$/ work identically — whether the pattern comes from a spec or from a new Schema(pattern: ...) value object.
Numeric Validation
minimum/maximum- Range constraintsexclusiveMinimum/exclusiveMaximum- Exclusive rangesmultipleOf- Numeric division
multipleOf for int64 values (e.g. snowflake IDs up to PHP_INT_MAX) works without ext-bcmath via pure-PHP decimal modulus; the bcmath extension is used for the faster path when available.
Array Validation
items/prefixItems- Array item validationminItems/maxItems- Array length constraintsuniqueItems- Unique item requirementcontains/minContains/maxContains- Item presence validation
uniqueItems, enum, and const use JSON instance equality: 1 and 1.0 are equal, object key order is irrelevant, and bool is distinct from int. Arrays larger than 100 000 items are rejected for uniqueItems instead of being scanned for duplicates.
Object Validation
properties- Property definitionsrequired- Required propertiesadditionalProperties- Additional property rulesminProperties/maxProperties- Property count constraintspatternProperties- Pattern-based property validationpropertyNames- Property name validationdependentSchemas- Conditional schema application
Composition Keywords
allOf- Must match all schemasanyOf- Must match at least one schemaoneOf- Must match exactly one schemanot- Must not match schemaif/then/else- Conditional validation
Advanced Keywords
$ref- Schema referencesdiscriminator- Polymorphic schemasunevaluatedProperties/unevaluatedItems- Dynamic evaluation
Error Handling
Validation Exceptions
All validation errors throw ValidationException which contains detailed error information:
use Duyler\OpenApi\Validator\Error\Formatter\SimpleFormatter; use Duyler\OpenApi\Validator\Exception\ValidationException; try { $operation = $validator->validateRequest($request); } catch (ValidationException $e) { // Get array of validation errors $errors = $e->getErrors(); foreach ($errors as $error) { printf( "Path: %s\nMessage: %s\nKeyword: %s\n\n", $error->dataPath(), $error->message(), $error->keyword() ); } // Format errors via a formatter $formatted = (new SimpleFormatter())->formatException($e); echo $formatted; }
Exception Sanitization
Every exception shipped by this package overrides __toString(), so (string) $e returns only the message — no file paths or stack traces. This makes exception messages safe to render into HTTP responses and logs.
Attacker-influenced values (the InvalidFormatException value, security scheme details, external $ref targets, parameter names) are stored non-public and exposed only through opt-in getters with a bool $reveal = false parameter; the default return is the literal string '<redacted>'. For example, InvalidFormatException::value(reveal: true) returns the raw value, and MissingSecurityCredentialsError exposes schemeName(reveal: true), schemeType(reveal: true), and location(reveal: true):
use Duyler\OpenApi\Validator\Exception\InvalidFormatException; try { $validator->validateSchema(['email' => 'not-an-email'], '#/components/schemas/User'); } catch (ValidationException $e) { /** @var InvalidFormatException $formatError */ $formatError = $e->getErrors()[0]; echo $formatError->message(); // safe to log or surface echo $formatError->value(); // '<redacted>' (default) echo $formatError->value(reveal: true); // 'not-an-email' (trusted operator only) }
Error formatters omit raw values unless constructed with includeSensitiveValues: true, or the builder is configured with withDetailedErrors(includeSensitive: true) (see Error Formatters).
Validation Error Reference
All errors implement ValidationErrorInterface and provide dataPath(), schemaPath(), keyword(), message(), params(), and suggestion() methods.
Type and Value Errors
| Error Type | Keyword | Description |
|---|---|---|
TypeMismatchError |
type |
Data type doesn't match schema type |
EnumError |
enum |
Value not in allowed enum |
ConstError |
const |
Value doesn't match constant |
InvalidDataTypeException |
invalid |
Invalid data type encountered |
Format Validation Errors
InvalidFormatException extends AbstractValidationError and is thrown by format validators rather than the schema validator.
| Error Type | Keyword | Description |
|---|---|---|
InvalidFormatException |
format |
Format validation failed (email, URI, etc.) |
String Validation Errors
| Error Type | Keyword | Description |
|---|---|---|
MinLengthError |
minLength |
String length below minimum |
MaxLengthError |
maxLength |
String length exceeds maximum |
PatternMismatchError |
pattern |
Regular expression pattern violation |
Numeric Validation Errors
| Error Type | Keyword | Description |
|---|---|---|
MinimumError |
minimum / exclusiveMinimum |
Value below minimum (inclusive/exclusive) |
MaximumError |
maximum / exclusiveMaximum |
Value exceeds maximum (inclusive/exclusive) |
MultipleOfKeywordError |
multipleOf |
Value is not a multiple of the specified number |
Note:
MinimumError::keyword()always returns'minimum'for bothminimumandexclusiveMinimumviolations. Similarly,MaximumError::keyword()always returns'maximum'. UseschemaPath()to distinguish between inclusive (/minimum,/maximum) and exclusive (/exclusiveMinimum,/exclusiveMaximum) constraints.
Array Validation Errors
| Error Type | Keyword | Description |
|---|---|---|
MinItemsError |
minItems |
Array has fewer items than required |
MaxItemsError |
maxItems |
Array has more items than allowed |
DuplicateItemsError |
uniqueItems |
Array contains duplicate items |
ContainsMatchError |
contains |
Array has no matching items for contains |
MinContainsError |
minContains |
Too few items match contains |
MaxContainsError |
maxContains |
Too many items match contains |
Object Validation Errors
| Error Type | Keyword | Description |
|---|---|---|
RequiredError |
required |
Required property is missing |
MinPropertiesError |
minProperties |
Object has fewer properties than required |
MaxPropertiesError |
maxProperties |
Object has more properties than allowed |
AdditionalPropertyError |
additionalProperties |
Additional property present despite additionalProperties: false |
UnevaluatedPropertyError |
unevaluatedProperties |
Property not allowed and not evaluated by any keyword |
ReadOnlyPropertyError |
readOnly |
Read-only property was sent in a request payload |
WriteOnlyPropertyError |
writeOnly |
Write-only property was returned in a response payload |
Composition Errors
| Error Type | Keyword | Description |
|---|---|---|
OneOfError |
oneOf |
Data matches multiple schemas (should match exactly one) |
NotValidationError |
not |
Data matches the schema forbidden by not |
DiscriminatorDataError |
oneOf |
Discriminator validation received non-object data |
Discriminator Errors
| Error Type | Keyword | Description |
|---|---|---|
DiscriminatorMismatchException |
discriminator |
Discriminator type doesn't match expected |
InvalidDiscriminatorValueException |
discriminator |
Discriminator property has wrong type |
UnknownDiscriminatorValueException |
discriminator |
Discriminator value not in mapping |
MissingDiscriminatorPropertyException |
discriminator |
Required discriminator property is missing |
Security Errors
| Error Type | Keyword | Description |
|---|---|---|
MissingSecurityCredentialsError |
security |
Required security credentials missing from request |
HTTP, Request, and Schema Errors
These exceptions extend RuntimeException, Exception, or InvalidArgumentException directly and do not implement ValidationErrorInterface:
| Exception | Description |
|---|---|
BodyTooLargeException |
Request or response body exceeded the configured body-size cap; rejected before full materialisation |
MultipartBodyTooLargeException |
Multipart request yielded more than 1000 parts (part-count cap); extends \RuntimeException |
BuilderException |
Builder precondition failure: spec file unreadable, withExternalRefAllowedRoot path does not exist, or other builder-state violation |
ExternalRefSecurityException |
External $ref violates builtin resolver security policy (non-allowlisted scheme, path traversal outside the allowed root). Surfaced by RefResolver as UnresolvableRefException |
ExternalRefTooLargeException |
External $ref file exceeds the configured size limit (default 10 MB); extends \RuntimeException |
InvalidMultipleOfSchemaException |
Schema declares multipleOf ≤ 0 (mathematically unsatisfiable) |
InvalidParameterException |
Parameter value is malformed or invalid |
InvalidPatternException |
Invalid regex pattern in schema definition |
InvalidSchemaException |
Spec parsing failed (malformed OpenAPI document) |
InvalidUtf8Exception |
Input is not valid UTF-8 (RFC 8259 §8.1) |
MalformedStreamRecordException |
Streaming response record (NDJSON / SSE / JSON Text Sequence) failed to parse AND enableStrictStreaming() is on; under default fail-open streaming the record is logged and skipped instead |
MissingParameterException |
Required parameter is missing from request |
MissingRequestBodyException |
Request body is required but missing or empty |
NestedValidationError |
Validation failure nested inside a composition branch whose specific cause could not be narrowed to a single keyword (composition fallback) |
OperationNotFoundException |
Request path or method does not match any operation in the specification |
PathMismatchException |
Request path doesn't match any operation template |
PregRuntimeException |
PCRE runtime failure (backtrack limit, recursion limit, or JIT stack exhaustion) while evaluating a JSON Schema pattern |
RefResolutionException |
Failed to resolve $ref reference |
SchemaDepthExceededException |
Maximum schema nesting depth exceeded |
SpecTooLargeException |
Spec payload exceeded maxSpecSize / maxSpecDepth, or YAML anchor/alias caps were tripped. Carries only the metric, actual count, and cap — never the payload |
TooManyContainsValidationsError |
contains keyword evaluated more matching items than the internal cap (DoS defence on attacker-controlled arrays) |
TooManyErrorsError |
Composition validator (oneOf / anyOf / allOf) accumulated more errors than the internal cap (DoS defence on deeply-nested schemas) |
TooManyItemsForUniqueCheckError |
uniqueItems: true evaluated on an array larger than 100 000 items; guards against quadratic uniqueness scans |
TooManyRecordsException |
Streaming response (NDJSON / SSE / JSON Text Sequence) yielded more records than maxStreamingRecords (default 100 000) |
UndefinedResponseException |
Response status code not defined in spec |
UnknownLinkException |
resolveLink() / resolveLinkWithContext() invoked with a link name not declared under components/links; extends \InvalidArgumentException |
UnknownCallbackException |
validateCallback() invoked with a callback name not declared in the spec; extends \InvalidArgumentException |
UnknownWebhookException |
validateWebhook() invoked with a webhook name not declared in the spec; extends \InvalidArgumentException |
UnresolvableCallbackPathException |
Callback runtime template (e.g. {$request.body#/callback_url}) cannot be resolved in strict mode |
UnresolvableRefException |
$ref cannot be resolved against the spec or allowed external-ref root. ExternalRefSecurityException is surfaced through this class |
UnsupportedMediaTypeException |
Content-Type not supported by the operation |
UnsupportedSecuritySchemeException |
Spec declares a security scheme type this library does not validate (oauth2, openIdConnect, http/basic, http/digest, mutualTLS, or unknown). Extends \RuntimeException, not wrapped into ValidationException (see Security Validation) |
VersionNotFoundException |
Requested schema name or version is not registered (thrown by SchemaRegistry::getOrFail()) |
SchemaAlreadyRegisteredException |
Schema name+version pair is already registered (thrown by SchemaRegistry::register(); use registerOrReplace() for explicit overwrite) |
ServerVariableException |
Server URL template substitution failed: a required {var} was missing from the configured ServerVariableOverride map, or the request URL did not match any declared server |
Error Formatters
Choose the appropriate error formatter for your use case:
use Duyler\OpenApi\Builder\OpenApiValidatorBuilder; use Duyler\OpenApi\Validator\Error\Formatter\SimpleFormatter; // default use Duyler\OpenApi\Validator\Error\Formatter\DetailedFormatter; // with suggestions use Duyler\OpenApi\Validator\Error\Formatter\JsonFormatter; // for API responses $validator = OpenApiValidatorBuilder::create() ->fromYamlFile('openapi.yaml') ->withErrorFormatter(new DetailedFormatter()) ->build();
To format a ValidationException without holding a reference to the validator, call ErrorFormatterInterface::formatException() directly:
use Duyler\OpenApi\Validator\Error\Formatter\SimpleFormatter; use Duyler\OpenApi\Validator\Exception\ValidationException; $formatter = new SimpleFormatter(); try { $operation = $validator->validateRequest($request); } catch (ValidationException $e) { echo $formatter->formatException($e); }
ErrorFormatterInterface exposes three methods; formatException() is the canonical entry point (the others are useful when you hold individual errors rather than a full exception):
| Method | Signature | Purpose |
|---|---|---|
format |
(ValidationErrorInterface $error): string |
Render a single validation error |
formatMultiple |
(array $errors): string |
Render a list of ValidationErrorInterface instances |
formatException |
(ValidationException $exception): string |
Render every error carried by a ValidationException |
By default, DetailedFormatter and JsonFormatter omit the raw user-supplied value from InvalidFormatException errors to prevent accidental disclosure of secrets (passwords, tokens) through error messages into logs and API responses. The value remains accessible via $exception->value(reveal: true) for trusted programmatic access (see Exception Sanitization). To include the raw value in formatted output (for debugging), construct the formatter with includeSensitiveValues: true or use withDetailedErrors(includeSensitive: true) on the builder.
Built-in Format Validators
The following format validators are included:
String Formats
| Format | Description | Example |
|---|---|---|
date-time |
ISO 8601 date-time | 2026-01-15T10:30:00Z |
date |
ISO 8601 date | 2026-01-15 |
time |
ISO 8601 time | 10:30:00Z |
email |
Email address (RFC 5321 + RFC 6531 SMTPUTF8) | user@example.com, 用户@例子.广告, user@[127.0.0.1] |
uri |
URI (RFC 3986 generic syntax) | https://example.com |
uuid |
UUID | 550e8400-e29b-41d4-a716-446655440000 |
hostname |
Hostname | example.com |
ipv4 |
IPv4 address | 192.168.1.1 |
ipv6 |
IPv6 address | 2001:db8::1 |
byte |
Base64-encoded data | SGVsbG8gd29ybGQ= |
duration |
ISO 8601 duration | P3Y6M4DT12H30M5S |
json-pointer |
JSON Pointer | /path/to/value |
relative-json-pointer |
Relative JSON Pointer | 1/property |
binary |
Binary file data hint (pass-through, OAS 3.2 §5.x) | <file content> |
password |
Password hint (pass-through, OAS 3.2 §5.x) | secret123! |
idn-email |
Internationalized email (RFC 6531 SMTPUTF8) | 用户@例子.广告 |
idn-hostname |
Internationalized hostname (RFC 5890 IDNA2008) | 例え.テスト |
iri |
Internationalized Resource Identifier (RFC 3987) | http://例え.テスト/path |
iri-reference |
Absolute or relative IRI (RFC 3987) | /path, //host/path, ?q=1 |
uri-reference |
Absolute or relative URI (RFC 3986 §4.1) | /path, //host/path, ?q=1, #frag |
uri-template |
URI Template (RFC 6570, balanced expressions) | https://api.example.com/users/{userId} |
regex |
Regular expression pattern (ECMA-262 syntax via PCRE) | ^[a-z]+$ |
The
timeformat requires a UTC offset per RFC 3339 §5.6 (Z,+HH:MM, or-HH:MM). Time strings without an offset (e.g.,10:30:00) are rejected withInvalidFormatException.
Numeric Formats
| Format | Description | Example |
|---|---|---|
float |
Floating-point number | 3.14 |
double |
Double-precision number | 3.14159265359 |
int32 |
Signed 32-bit integer (range [-2147483648, 2147483647]) |
42 |
int64 |
Signed 64-bit integer (range [PHP_INT_MIN, PHP_INT_MAX]) |
9223372036854775807 |
Overriding Built-in Validators
Replace built-in validators with custom implementations:
$customEmailValidator = new class implements FormatValidatorInterface { public function validate(mixed $data): void { // Custom email validation logic if (!filter_var($data, FILTER_VALIDATE_EMAIL)) { throw new InvalidFormatException('email', $data, 'Invalid email'); } } }; $validator = OpenApiValidatorBuilder::create() ->fromYamlFile('openapi.yaml') ->withFormat('string', 'email', $customEmailValidator) ->build();
Migration from league/openapi-psr7-validator
Key Differences
| Feature | league/openapi-psr7-validator | duyler/openapi |
|---|---|---|
| PHP Version | PHP 7.4+ | PHP 8.4+ |
| OpenAPI Version | 3.0 | 3.0, 3.1, 3.2 |
| JSON Schema | Draft 7 | Draft 2020-12 |
| Builder Pattern | Fluent builder | Fluent builder (immutable) |
| Type Coercion | Enabled by default | Opt-in |
| Error Formatting | Basic | Multiple formatters |
Migration Examples
Warning: Migration from
league/openapi-psr7-validatorrequires rewriting your routing layer.leagueprovidedOperationAddress+PathParamsutilities;duyler/openapireturns a simplerOperationDTO that already carries a resolvedpathParametersmap. Additionally, coercion is opt-in here (enableCoercion()), whereasleaguehad it enabled by default — this is a behavioral breaking change for migrants.
Before (league/openapi-psr7-validator)
use League\OpenAPIValidation\PSR7\ValidatorBuilder; $builder = new ValidatorBuilder(); $builder->fromYamlFile('openapi.yaml'); $requestValidator = $builder->getRequestValidator(); $responseValidator = $builder->getResponseValidator(); // Request validation $requestValidator->validate($request); // Response validation $responseValidator->validate($operationAddress, $response);
After (duyler/openapi)
use Duyler\OpenApi\Builder\OpenApiValidatorBuilder; $validator = OpenApiValidatorBuilder::create() ->fromYamlFile('openapi.yaml') ->enableCoercion() ->build(); // Request validation - path and method are automatically detected $operation = $validator->validateRequest($request); // Response validation $validator->validateResponse($response, $operation); // Schema validation $validator->validateSchema($data, '#/components/schemas/User');
Performance
Reuse a single validator instance across requests: building it (spec parsing and validator construction) is far more expensive than validating. Per-request validation cost is dominated by body size and schema complexity; for production sizing, measure against your own specs, hardware, and PHP version rather than relying on generic figures.
Caching
Enable PSR-6 caching when the OpenAPI specification does not change between requests. This skips YAML/JSON parsing and schema construction on every build:
use Symfony\Component\Cache\Adapter\FilesystemAdapter; use Duyler\OpenApi\Cache\SchemaCache; use Duyler\OpenApi\Builder\OpenApiValidatorBuilder; $cachePool = new FilesystemAdapter(); $schemaCache = new SchemaCache($cachePool, 3600); // TTL in seconds, default 3600 $validator = OpenApiValidatorBuilder::create() ->fromYamlFile('openapi.yaml') ->withCache($schemaCache) ->build();
SchemaCache wraps any PSR-6 pool with a TTL and derives its keys from the spec content plus the builder settings that affect validation (size/depth caps, external-ref configuration), so a changed spec or configuration automatically produces a new key. The external-$ref confinement verdict for string-loaded specs is cached alongside the document under the same pool and TTL; a failing verdict is never cached. Entries can also be managed directly via get() / set() / delete() / clear() — useful for cache warming at deploy time or selective invalidation after a hot reload.
Long-Running Processes
The validator instance is safe to reuse across requests in long-running processes that use the prefork execution model (PHP-FPM, RoadRunner, FrankenPHP non-threaded) — no extra configuration needed. Under Swoole coroutines or FrankenPHP threaded workers, shared mutable state requires care: give the validator pool a lock (ValidatorPool::forCoroutineRuntime(new \Swoole\Lock())) or use one pool per coroutine/worker, as described in Validator Pool. XML body validation uses libxml global state, and functions like json_last_error() and DateTime::getLastErrors() are process-global, so these code paths are not coroutine-safe. If the OpenAPI specification changes at runtime, rebuild the validator; old instances are garbage-collected when no longer referenced.
Streaming Response Validation
The validator supports three streaming response formats. Each item in the stream is validated individually against the schema declared with the itemSchema keyword (or schema as fallback) in the media type definition:
Supported Content Types
| Format | Content-Type | Specification |
|---|---|---|
| JSON Lines / NDJSON | application/jsonl or application/x-ndjson |
Newline-delimited JSON objects |
| Server-Sent Events | text/event-stream |
W3C SSE specification |
| JSON Text Sequences | application/json-seq |
RFC 7464 |
OpenAPI Specification
openapi: '3.2.0' info: title: Streaming API version: '1.0.0' paths: /logs: get: operationId: getLogs responses: '200': description: Log stream content: application/jsonl: itemSchema: type: object properties: timestamp: type: string format: date-time level: type: string enum: [debug, info, warn, error] message: type: string required: [timestamp, level, message]
NDJSON / JSON Lines
Each line in the response body is a separate JSON object. Empty lines are skipped.
use Nyholm\Psr7\Factory\Psr17Factory; use Duyler\OpenApi\Builder\OpenApiValidatorBuilder; $factory = new Psr17Factory(); $validator = OpenApiValidatorBuilder::create() ->fromYamlFile('openapi.yaml') ->build(); $request = $factory->createServerRequest('GET', '/logs'); $operation = $validator->validateRequest($request); $body = '{"timestamp":"2024-01-01T00:00:00Z","level":"info","message":"Started"}' . "\n" . '{"timestamp":"2024-01-01T00:00:01Z","level":"error","message":"Failed"}'; $response = $factory->createResponse(200) ->withHeader('Content-Type', 'application/jsonl') ->withBody($factory->createStream($body)); $validator->validateResponse($response, $operation);
Server-Sent Events (SSE)
Each event consists of event, data, id, and retry fields; comment lines (starting with :) are ignored. The data field is automatically decoded from JSON when possible, and an event that carries data: but no event: field is assigned the W3C default event type message.
JSON Text Sequences (RFC 7464)
Each record is prefixed with a record separator byte (0x1E), which avoids ambiguity with newlines inside JSON strings.
Error Handling in Streams
By default, malformed records (invalid JSON) are logged and skipped: the parser yields null for such items and the validator skips them. Enable enableStrictStreaming() to throw MalformedStreamRecordException instead. A parsed item that fails schema validation always throws ValidationException immediately, and a stream yielding more than withMaxStreamingRecords() records (default 100 000) throws TooManyRecordsException.
Limitations
JSON Schema Coverage
The validator covers approximately 95% of JSON Schema draft 2020-12 keywords. The following are not fully supported:
$dynamicRef/$dynamicAnchor- dynamic schema resolution$recursiveRef/$recursiveAnchor- recursive schema resolutioncontentEncoding/contentMediaType- limited support: base64 encoding and a fixed set of common media types (JSON, XML, text); custom encodings and media types are not supported.- Custom vocabularies and keyword extensions
The boolean schema form (true / false) is supported everywhere a schema is expected: true always passes and false always rejects.
$ref and null values (known limitation)
When a properties or items subschema is declared as {$ref: '#/...'} and only the resolved target allows null (via type: [..., 'null'] rather than a nullable: true sibling on the $ref stub), a null value on that property or item is rejected as InvalidDataTypeException even though the resolved target allows it. Non-null values are unaffected. Workaround: declare nullable: true as a sibling of the $ref — the sibling flag and the target's own nullability are combined with AND semantics, per OpenAPI 3.x nullable sibling-extension rules.
unevaluatedProperties / unevaluatedItems
Both keywords are honoured — including across allOf / anyOf / oneOf, if/then/else, $ref, contains, and discriminator-routed branches — through the canonical validator path returned by OpenApiValidatorBuilder::build().
Content Negotiation
Request body validation honours RFC 7231 §3.1.1.1 wildcard patterns declared in the OpenAPI specification. The most specific declaration wins:
- Exact match (e.g.,
application/json) - Subtype wildcard (e.g.,
application/*) - Universal wildcard (
*/*)
When a wildcard declaration matches, the request body is parsed according to the concrete Content-Type sent by the client, so a spec application/* with a request Content-Type: application/json is decoded as JSON. A request whose Content-Type does not match any declared media type is rejected with UnsupportedMediaTypeException (fail-closed).
Response body validation does not expand wildcards: media type matching uses literal string comparison, and a response Content-Type that does not match a declared media type simply skips response body validation.
Security Validation
Security scheme validation is opt-in (enableSecurityValidation()) and performs presence-only checks: it verifies that required credentials are present in the request but does not verify their correctness. Token introspection, JWT verification, JWKS resolution, and OAuth flows are out of scope.
Supported scheme types:
http/bearer- checks for theAuthorization: Bearer ...header (case-insensitive scheme prefix)apiKey(query,header,cookie) - checks for the named parameter in the specified location
Any other scheme type declared in the spec (http/basic, http/digest, oauth2, openIdConnect, mutualTLS, or unknown) throws UnsupportedSecuritySchemeException at request-validation time. The exception extends \RuntimeException and is not wrapped into ValidationException, so catch it separately in your middleware.
The security keyword is a list of OR-alternatives, each an AND-dict of schemes. An AND list containing an unsupported scheme fails closed with UnsupportedSecuritySchemeException, even when a supported sibling in the same dict would have passed. In an OR list, supported alternatives are still tried; if none succeeds, the unsupported-scheme error is re-thrown.
By default, credential failures return a generic message ('Authentication required: missing or invalid credentials') that does not reveal which scheme was checked or where the credential was expected. Provide a PSR-3 logger via withSecurityVerboseLogging() to get scheme details at debug level (declared OAuth2 scopes are logged there as well), or read them programmatically via the opt-in reveal: true getters (see Exception Sanitization).
To validate OAuth2 / OpenID Connect tokens, remove those schemes from the spec and validate the token at the application layer — for example, in a PSR-15 middleware calling your introspection endpoint or JWKS verifier. The security validator has no extension point for custom scheme handlers; a missing handler is reported via the exception instead of silently bypassed.
Requirements
- PHP 8.4 or higher - Uses modern PHP features (readonly classes, match expressions, etc.)
- PSR-7 HTTP message -
psr/http-message ^2.0. Use any PSR-7 implementation (nyholm/psr7,guzzle/psr7,laminas/laminas-diactoros). - PSR-6 cache -
psr/cache ^3.0(e.g.,symfony/cache,cache/cache) - PSR-14 events -
psr/event-dispatcher ^1.0(e.g.,symfony/event-dispatcher) - PSR-3 logging -
psr/log ^3.0(included, optional to use viawithLogger()) - YAML parser -
symfony/yaml ^7.0 || ^8.0
Testing
# Run tests make tests # Run with coverage make coverage # Run static analysis make psalm # Fix code style make cs-fix
License
MIT