duyler / openapi
Duyler openapi validator
Requires
- php: ^8.4
- nyholm/psr7: ^1.8
- psr/cache: ^3.0
- psr/event-dispatcher: ^1.0
- psr/http-message: ^2.0
- symfony/yaml: ^7.0 || ^8.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.80
- infection/infection: ^0.32
- phpunit/phpunit: ^13.0
- rector/rector: ^2.0
- vimeo/psalm: ^6.10
README
OpenAPI 3.2 validator for PHP 8.4+
Features
- Full OpenAPI 3.2 Support - Complete implementation of OpenAPI 3.2 specification
- JSON Schema Validation - Full JSON Schema draft 2020-12 validation with 25+ validators
- PSR-7 Integration - PSR-7 HTTP message validation (requires nyholm/psr7)
- 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 - 15 built-in validators (email, UUID, date-time, URI, IPv4/IPv6, 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
- Validator Compilation - Generate optimized validator code
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); // ... } }
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 |
getFormattedErrors(ValidationException $e): string |
Format validation errors as string |
validateWebhook(ServerRequestInterface $request, string $name): Operation |
Validate webhook request |
validateCallback(ServerRequestInterface $request, string $name): Operation |
Validate callback request |
resolveLink(string $linkName, array $responseData): array |
Resolve link parameters from response data (body only) |
resolveLinkWithContext(string $linkName, LinkContext $context): array |
Resolve link parameters with full Runtime Expression support ($response.body/header/query, $url, $method, $statusCode) |
reset(): void |
Reset validator state for reuse |
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();
PSR-7 Integration
The validator uses nyholm/psr7 as the PSR-7 implementation:
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
Caching
Enable PSR-6 caching to skip YAML/JSON parsing and schema construction on every build. See the Caching section under Performance for configuration details and compiled validator caching.
Events
Subscribe to validation events using PSR-14:
use Duyler\OpenApi\Event\ArrayDispatcher; use Duyler\OpenApi\Event\ValidationStartedEvent; use Duyler\OpenApi\Builder\OpenApiValidatorBuilder; $dispatcher = new ArrayDispatcher([ ValidationStartedEvent::class => [ function (ValidationStartedEvent $event) { printf("Validating: %s %s\n", $event->method, $event->path); }, ], ]); $validator = OpenApiValidatorBuilder::create() ->fromYamlFile('openapi.yaml') ->withEventDispatcher($dispatcher) ->build();
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');
Link Resolution
Resolve OpenAPI Link parameters from response data:
use Duyler\OpenApi\Validator\Link\LinkContext; // Simple resolution (response body only) $result = $validator->resolveLink('GetUserById', ['id' => 42, 'name' => 'John']); // 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, ); $result = $validator->resolveLinkWithContext('GetUserById', $context);
resolveLink() only resolves $response.body expressions. Use resolveLinkWithContext() for full Runtime Expression support: $response.body, $response.header, $response.query, $url, $method, and $statusCode. Note that $request.body and $request.query expressions are not supported.
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();
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();
Error Formatters
Choose from built-in error formatters or create your own:
use Duyler\OpenApi\Validator\Error\Formatter\DetailedFormatter; use Duyler\OpenApi\Validator\Error\Formatter\JsonFormatter; // Detailed formatter with suggestions $validator = OpenApiValidatorBuilder::create() ->fromYamlFile('openapi.yaml') ->withErrorFormatter(new DetailedFormatter()) ->build(); // JSON formatter for API responses $validator = OpenApiValidatorBuilder::create() ->fromYamlFile('openapi.yaml') ->withErrorFormatter(new JsonFormatter()) ->build(); try { $operation = $validator->validateRequest($request); } catch (ValidationException $e) { // Get formatted errors $formatted = $validator->getFormattedErrors($e); echo $formatted; }
Discriminator Validation
Validate polymorphic schemas with discriminators:
$yaml = <<<YAML openapi: 3.2.0 info: title: Pet Store API version: 1.0.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');
Event-Driven Validation
Subscribe to validation lifecycle events:
use Duyler\OpenApi\Event\ValidationStartedEvent; use Duyler\OpenApi\Event\ValidationFinishedEvent; use Duyler\OpenApi\Event\ValidationErrorEvent; use Duyler\OpenApi\Event\ValidationWarningEvent; use Duyler\OpenApi\Event\ArrayDispatcher; $dispatcher = new ArrayDispatcher([ ValidationStartedEvent::class => [ function (ValidationStartedEvent $event) { error_log(sprintf( "Validation started: %s %s", $event->method, $event->path )); }, ], ValidationFinishedEvent::class => [ function (ValidationFinishedEvent $event) { if ($event->success) { error_log(sprintf( "Validation completed in %.3f seconds", $event->duration )); } }, ], ValidationErrorEvent::class => [ function (ValidationErrorEvent $event) { error_log(sprintf( "Validation failed for %s %s: %s", $event->method, $event->path, $event->exception->getMessage() )); }, ], ValidationWarningEvent::class => [ function (ValidationWarningEvent $event) { error_log(sprintf( "Warning at %s (property: %s, schema: %s): %s", $event->propertyPath, $event->propertyName, $event->schemaRef ?? 'unknown', $event->message )); }, ], ]); $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 |
Schema Registry
Manage multiple API versions:
Warning:
getDocument()is a method of the concreteOpenApiValidatorclass, not theOpenApiValidatorInterface. If you type-hint the interface, this method is unavailable and will cause a runtime error. Type-hint the concrete class directly or perform aninstanceofcheck before calling it.
use Duyler\OpenApi\Builder\OpenApiValidatorBuilder; use Duyler\OpenApi\Validator\OpenApiValidator; use Duyler\OpenApi\Registry\SchemaRegistry; use LogicException; // Load multiple versions $validatorV1 = OpenApiValidatorBuilder::create() ->fromYamlFile('api-v1.yaml') ->build(); if (!($validatorV1 instanceof OpenApiValidator)) { throw new LogicException('Validator must be an instance of OpenApiValidator to access getDocument()'); } $documentV1 = $validatorV1->getDocument(); $validatorV2 = OpenApiValidatorBuilder::create() ->fromYamlFile('api-v2.yaml') ->build(); if (!($validatorV2 instanceof OpenApiValidator)) { throw new LogicException('Validator must be an instance of OpenApiValidator to access getDocument()'); } $documentV2 = $validatorV2->getDocument(); // Register schemas $registry = new SchemaRegistry(); $registry = $registry ->register('api', '1.0.0', $documentV1) ->register('api', '2.0.0', $documentV2); // Get specific version $schema = $registry->get('api', '1.0.0'); // Get latest version (sorted by semver) $schema = $registry->get('api'); // List all versions $versions = $registry->getVersions('api'); // ['1.0.0', '2.0.0'] // Check if a schema exists $registry->has('api', '1.0.0'); // true $registry->has('api'); // true $registry->has('unknown'); // false // List all registered schema names $names = $registry->getNames(); // ['api'] // Count schemas and versions $total = $registry->count(); // 1 $apiVersions = $registry->countVersions('api'); // 2
The registry is immutable: register() returns a new instance with the added schema.
Validator Pool
The validator pool uses an LRU (Least Recently Used) cache to reuse validator instances. The default capacity is 128 entries. When the pool is full, the least recently used validator is evicted.
use Duyler\OpenApi\Validator\ValidatorPool; $pool = new ValidatorPool(); // default: 128 entries $pool = new ValidatorPool(maxSize: 64); // custom capacity // Validators are automatically reused and evicted when capacity is exceeded $validator = OpenApiValidatorBuilder::create() ->fromYamlFile('openapi.yaml') ->withValidatorPool($pool) ->build();
Validator Compilation
Generate optimized validator code:
use Duyler\OpenApi\Compiler\ValidatorCompiler; use Duyler\OpenApi\Schema\Model\Schema; $schema = new Schema( type: 'object', properties: [ 'name' => new Schema(type: 'string'), 'age' => new Schema(type: 'integer'), ], required: ['name', 'age'], ); $compiler = new ValidatorCompiler(); $code = $compiler->compile($schema, 'UserValidator'); // Save generated validator file_put_contents('UserValidator.php', $code); // Use generated validator require_once 'UserValidator.php'; $validator = new UserValidator(); $validator->validate(['name' => 'John', 'age' => 30]);
The compiler generates a standalone PHP class with hardcoded validation rules. It does not depend on the library at runtime.
Compilation with $ref Resolution
Use compileWithRefResolution() to inline $ref references from an OpenAPI document:
use Duyler\OpenApi\Compiler\ValidatorCompiler; use Duyler\OpenApi\Schema\OpenApiDocument; $compiler = new ValidatorCompiler(); // Resolve $ref pointers against the document before compiling $code = $compiler->compileWithRefResolution($schema, 'PetValidator', $document);
Circular references are detected and throw a RuntimeException.
Compilation with Caching
Use compileWithCache() to avoid recompiling the same schema:
use Duyler\OpenApi\Compiler\ValidatorCompiler; use Duyler\OpenApi\Compiler\CompilationCache; use Symfony\Component\Cache\Adapter\FilesystemAdapter; $cachePool = new FilesystemAdapter(); $compilationCache = new CompilationCache($cachePool); $compiler = new ValidatorCompiler(); // First call compiles and caches, subsequent calls return cached code $code = $compiler->compileWithCache($schema, 'UserValidator', $compilationCache);
CompilationCache uses a PSR-6 cache pool and generates a SHA-256 hash of the schema to use as the cache key. Cached entries expire after 24 hours (86400 seconds). This TTL is hardcoded and not configurable.
Compiler Limitations
The compiler does not support all JSON Schema keywords. If a schema uses unsupported keywords (allOf, anyOf, oneOf, not, if/then/else, patternProperties), the compiler throws UnsupportedKeywordException. See the Limitations section below for details.
Configuration Options
Builder Methods
| Method | Description | Default |
|---|---|---|
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 |
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 |
enableNullableAsType() |
Enable nullable validation (default: true) | 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 |
Deprecated reporting is enabled by default. Without a PSR-3 logger, deprecation warnings go to NullLogger and produce no output. There is no disableReportDeprecated() method; to suppress deprecation warnings, simply omit the logger (the default behavior).
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
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' => $e->getMessage()], 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));
Note: The PSR-15 interfaces require the
psr/http-server-middlewarepackage, typically provided by your framework.
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)
Nullable Validation
By default, the nullable: true schema keyword allows null values for a property:
properties: username: type: string nullable: true # Allows null values
This behavior is enabled by default. To disable nullable validation and treat nullable: true as not allowing null values:
$validator = OpenApiValidatorBuilder::create() ->fromYamlFile('openapi.yaml') ->disableNullableAsType() // Optional: disable nullable validation ->build();
String Validation
minLength/maxLength- String length constraintspattern- Regular expression patternformat- Format validation (email, uri, uuid, date-time, etc.)
Pattern Validation
All regular expressions in schemas are validated during schema parsing. If a pattern is invalid, an InvalidPatternException is thrown.
Supported Pattern Fields
pattern- Regular expression for string validationpatternProperties- Object with patterns for property keyspropertyNames- Pattern for property name validation
Pattern Delimiters
The library automatically adds delimiters (/) to patterns without them. You can specify patterns with or without delimiters:
// Without delimiters (recommended) new Schema(pattern: '^test$') // With delimiters new Schema(pattern: '/^test$/')
Both variants work identically.
Pattern Validation Errors
Invalid patterns are detected early and throw descriptive errors:
// This will throw InvalidPatternException: // Invalid regex pattern "/[invalid/": preg_match(): No ending matching delimiter ']' found new Schema(pattern: '[invalid')
Numeric Validation
minimum/maximum- Range constraintsexclusiveMinimum/exclusiveMaximum- Exclusive rangesmultipleOf- Numeric division
Array Validation
items/prefixItems- Array item validationminItems/maxItems- Array length constraintsuniqueItems- Unique item requirementcontains/minContains/maxContains- Item presence validation
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\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\nType: %s\n\n", $error->dataPath(), $error->message(), $error->getType() ); } // Get formatted errors $formatted = $validator->getFormattedErrors($e); echo $formatted; }
Validation Error Reference
All errors implement ValidationErrorInterface and provide dataPath(), schemaPath(), keyword(), message(), params(), suggestion(), and getType() methods. The getType() method returns the validation keyword that triggered the error (e.g., 'type', 'minLength', 'format').
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 RuntimeException and implements ValidationErrorInterface directly (without extending AbstractValidationError). It 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 |
UnevaluatedPropertyError |
unevaluatedProperties |
Property not allowed and not evaluated by any keyword |
Composition Errors
| Error Type | Keyword | Description |
|---|---|---|
OneOfError |
oneOf |
Data matches multiple schemas (should match exactly one) |
AnyOfError |
anyOf |
Data doesn't match any of the schemas |
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 |
|---|---|
MissingParameterException |
Required parameter is missing from request |
MissingRequestBodyException |
Request body is required but missing |
EmptyBodyException |
Request body is empty |
UnsupportedMediaTypeException |
Content-Type not supported by the operation |
PathMismatchException |
Request path doesn't match any operation template |
InvalidParameterException |
Parameter value is malformed or invalid |
InvalidPatternException |
Invalid regex pattern in schema definition |
UndefinedResponseException |
Response status code not defined in spec |
RefResolutionException |
Failed to resolve $ref reference |
SchemaDepthExceededException |
Maximum schema nesting depth exceeded |
UnknownValidatorException |
Unknown validator type requested |
Error Formatters
Choose the appropriate error formatter for your use case:
// Simple formatter (default) use Duyler\OpenApi\Validator\Error\Formatter\SimpleFormatter; // Detailed formatter with suggestions use Duyler\OpenApi\Validator\Error\Formatter\DetailedFormatter; // JSON formatter for API responses use Duyler\OpenApi\Validator\Error\Formatter\JsonFormatter;
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 | user@example.com |
uri |
URI | 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 |
Numeric Formats
| Format | Description | Example |
|---|---|---|
float |
Floating-point number | 3.14 |
double |
Double-precision number | 3.14159265359 |
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
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
Benchmark Results
The following measurements come from the test suite benchmarks run on a standard development machine. Actual numbers vary depending on hardware, PHP version, and schema complexity.
| Scenario | Schema | Avg per validation | Memory per request |
|---|---|---|---|
| Simple (GET /ping) | 1 path, no body | < 5 ms | - |
| Medium (POST /users) | 4 properties, format validation, enum | < 10 ms | - |
| Complex (petstore.yaml) | Multiple paths, $ref, nested schemas |
< 10 ms | - |
| Path scanning | 100 routes, 50 iterations | < 100 ms total | < 1 MB growth |
| Full request+response cycle | 2 properties, email format | - | < 50 KB |
These numbers represent upper bounds enforced by assertions in tests/Benchmark/PerformanceBenchmarkTest.php. Real-world performance is typically better.
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: 1 hour $validator = OpenApiValidatorBuilder::create() ->fromYamlFile('openapi.yaml') ->withCache($schemaCache) ->build();
For compiled validators, use CompilationCache to avoid regenerating PHP code:
use Duyler\OpenApi\Compiler\ValidatorCompiler; use Duyler\OpenApi\Compiler\CompilationCache; use Symfony\Component\Cache\Adapter\FilesystemAdapter; $compilationCache = new CompilationCache(new FilesystemAdapter()); $compiler = new ValidatorCompiler(); $code = $compiler->compileWithCache($schema, 'UserValidator', $compilationCache);
When to Use Compilation
The ValidatorCompiler generates standalone PHP classes with hardcoded validation rules. This is faster than runtime schema traversal because the compiled code has no reflection, no $ref resolution, and no dynamic dispatch.
Use compilation when:
- The schema is stable and does not change at runtime
- You need maximum throughput for hot-path validation
- The schema uses only basic keywords (no
allOf,anyOf,oneOf,not,if/then/else)
Stick with runtime validation when:
- The schema changes frequently or is user-defined
- You need composition keywords (
allOf,anyOf,oneOf) - You need
$refresolution against an OpenAPI document (usecompileWithRefResolution()instead)
Coercion Impact
Enabling coercion with enableCoercion() adds a type conversion pass before validation. For request parameters (query, path, headers), this converts string values to their declared types (e.g., "123" to 123). The overhead is proportional to the number of parameters and properties in the request body. For most APIs, the cost is negligible compared to the validation itself.
Memory Profiling
The validator creates a fixed set of objects during build(). Per-request memory usage stays under 50 KB for typical schemas. To profile memory in your application:
$validator = OpenApiValidatorBuilder::create() ->fromYamlFile('openapi.yaml') ->build(); gc_collect_cycles(); $before = memory_get_usage(); $operation = $validator->validateRequest($request); $validator->validateResponse($response, $operation); gc_collect_cycles(); $after = memory_get_usage(); printf("Memory delta: %d bytes\n", $after - $before);
Long-Running Processes
The validator instance is safe to reuse across requests in long-running processes (RoadRunner, FrankenPHP, Swoole). The internal ValidatorPool uses an LRU cache to reuse validator instances without manual cleanup. The pool has a default capacity of 128 entries and automatically evicts the least recently used entries when full.
// Build once at worker startup $validator = OpenApiValidatorBuilder::create() ->fromYamlFile('openapi.yaml') ->withCache($schemaCache) ->build(); // Reuse across requests while ($request = $worker->waitRequest()) { $operation = $validator->validateRequest($request); // ... }
If the OpenAPI specification changes at runtime, rebuild the validator. The old instances will be 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 defined in itemSchema (or schema as fallback).
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
Use the itemSchema keyword within the media type definition to declare the schema for each individual item in the stream:
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 /events: get: operationId: getEvents responses: '200': description: Event stream content: text/event-stream: itemSchema: type: object properties: event: type: string data: type: object properties: message: type: string count: type: integer required: - event - data /records: get: operationId: getRecords responses: '200': description: Record stream content: application/json-seq: itemSchema: type: object properties: id: type: string value: type: string required: - id
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)
The parser handles the standard SSE format with event, data, and id fields. Comments (lines starting with :) are ignored. The data field is automatically decoded from JSON when possible.
$request = $factory->createServerRequest('GET', '/events'); $operation = $validator->validateRequest($request); $body = "event: message\n" . "data: {\"message\":\"hello\",\"count\":1}\n\n" . "event: update\n" . "data: {\"message\":\"world\",\"count\":2}\n\n"; $response = $factory->createResponse(200) ->withHeader('Content-Type', 'text/event-stream') ->withBody($factory->createStream($body)); $validator->validateResponse($response, $operation);
JSON Text Sequences (RFC 7464)
Each record is prefixed with a record separator byte (0x1E). This format avoids ambiguity with newlines inside JSON strings.
$request = $factory->createServerRequest('GET', '/records'); $operation = $validator->validateRequest($request); $body = "\x1E" . '{"id":"1","value":"first"}' . "\x1E" . '{"id":"2","value":"second"}'; $response = $factory->createResponse(200) ->withHeader('Content-Type', 'application/json-seq') ->withBody($factory->createStream($body)); $validator->validateResponse($response, $operation);
Error Handling in Streams
When a stream item fails to parse (invalid JSON), the parser logs a warning and yields null for that item. The validator skips null items. When a parsed item fails schema validation, a ValidationException is thrown immediately.
use Duyler\OpenApi\Validator\Response\StreamingContentParser; use Psr\Log\LoggerInterface; // Custom logger to track parse failures $parser = new StreamingContentParser($logger); // Returns [valid, null, valid] - second item is null due to invalid JSON $items = $parser->parseJsonLines('{"ok":true}' . "\n" . 'bad json' . "\n" . '{"ok":false}');
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- content validation- Custom vocabularies and keyword extensions
Validator Compiler
The ValidatorCompiler is marked as @experimental. It supports a subset of JSON Schema keywords: type, enum, const, minLength, maxLength, minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf, pattern, minItems, maxItems, uniqueItems, properties, required, additionalProperties, items.
The compiler does not support composition keywords (allOf, anyOf, oneOf, not), conditional keywords (if/then/else), or patternProperties. If any of these are present in a schema, compile() throws UnsupportedKeywordException.
Generated validators throw generic RuntimeException on failure rather than the typed error classes used by the runtime validator.
Security Validation
Security scheme validation is basic. The validator checks that required credentials are present in the request (headers, query parameters, or cookies) but does not verify their correctness or format. Token validation, signature checking, and OAuth flow handling are outside the scope of this library.
The following security scheme types are supported:
http/bearer- Checks forAuthorization: Bearer ...headerapiKey(query, header, cookie) - Checks for the named parameter in the specified location
The following scheme types are not supported and will produce an error when encountered:
http/basic- Basic authenticationoauth2- OAuth 2.0 flowsopenIdConnect- OpenID Connect Discovery
Requirements
- PHP 8.4 or higher - Uses modern PHP features (readonly classes, match expressions, etc.)
- PSR-7 HTTP message -
psr/http-message ^2.0(required:nyholm/psr7 ^1.8) - 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