cebe/php-openapi
Read and write OpenAPI 3.0.x YAML and JSON files as PHP objects, with a CLI tool for validating and converting descriptions. A low-level library used by higher-level API tooling.
Install the package alongside your Laravel app:
composer require cebe/php-openapi
This gives you two things: the cebe\openapi\Reader/Writer API for programmatic access, and a vendor/bin/php-openapi CLI binary for validation and conversion. For a Laravel project you'll typically want both.
Where to look first. Open Reader, Writer, and the cebe\openapi\spec\* classes. Every OpenAPI object (OpenApi, Info, PathItem, Operation, Schema, etc.) is a typed PHP class with public properties matching the spec field names — so $spec->info->title, $spec->paths['/users']->get->responses['200'], and $spec->components->schemas['User'] map 1:1 to the YAML you already know. Skim the "Completeness" table in the README to confirm the object you need is implemented (it's essentially complete for 3.0.x).
Your first use case — turn a spec you already have into a validated, introspectable object inside an artisan command:
use cebe\openapi\Reader;
$path = base_path('openapi.yaml');
$spec = Reader::readFromYamlFile(realpath($path)); // realpath matters, see Gotchas
if (!$spec->validate()) {
foreach ($spec->getErrors() as $error) {
$this->error($error);
}
return self::FAILURE;
}
$this->info($spec->info->title . ' ' . $spec->info->version);
$this->info('Paths: ' . count(iterator_to_array($spec->paths)));
Run php artisan tinker and inspect the parsed object — this is the fastest way to learn the shape of the API before wiring it into anything.
1. Spec-driven request validation. The most common Laravel use is reading the spec once and validating incoming requests against it. cebe/php-openapi gives you the parsed object; pair it with league/openapi-psr7-validator (listed as a dependent) for actual request validation. Wrap the read in a singleton bound in a service provider so you don't parse the YAML on every request:
// AppServiceProvider::register()
$this->app->singleton(\cebe\openapi\spec\OpenApi::class, function () {
return \cebe\openapi\Reader::readFromYamlFile(
realpath(config('openapi.path'))
);
});
Then a middleware or FormRequest-style validator resolves the OpenApi instance and validates the PSR-7 request.
2. Artisan commands as the day-to-day surface. Put spec operations behind commands so they're discoverable and CI-friendly:
// php artisan openapi:validate
$spec = Reader::readFromYamlFile(realpath($this->argument('file')));
exit($spec->validate() ? 0 : 2);
openapi:convert (YAML ↔ JSON), openapi:inline (bundle external $refs into one file for consumers), and openapi:diff (read two specs and report added/removed paths) are all short commands built directly on Reader/Writer.
3. Generating specs from Laravel models/routes. When the spec is the source of truth in code, build it with the spec\* classes and serialize on deploy:
use cebe\openapi\spec\{OpenApi, Info, Paths, PathItem, Operation, Responses, Response};
$openapi = new OpenApi([
'openapi' => '3.0.3',
'info' => new Info(['title' => config('app.name'), 'version' => '1.0.0']),
'paths' => new Paths(),
]);
$openapi->paths->getPaths()['/health'] = new PathItem([
'get' => new Operation([
'responses' => new Responses(['200' => new Response(['description' => 'OK'])]),
]),
]);
file_put_contents(storage_path('app/openapi.json'), Writer::writeToJson($openapi));
Commit the generated file (or upload it as a build artifact) so downstream consumers — spectator for contract tests, API gateways, client SDK generators — have a stable contract.
4. Contract testing with spectator. hotmeteor/spectator (a dependent) validates your Laravel test requests against the spec. The flow is: export the spec in a test setup step using Writer, then assert each feature test response conforms. This gives you regression protection on the API surface without hand-maintaining JSON schemas.
5. Reference resolution for bundled distribution. If your spec splits $refs across components/*.yaml, run:
vendor/bin/php-openapi inline openapi.yaml openapi-bundled.yaml
in CI and ship the bundled artifact. Programmatically:
use cebe\openapi\ReferenceContext;
$spec = Reader::readFromYamlFile(realpath($path), OpenApi::class, ReferenceContext::RESOLVE_MODE_INLINE);
\cebe\openapi\Writer::writeToYamlFile($spec, $out);
RESOLVE_MODE_INLINE keeps internal #/components/... refs but pulls in external files.
6. Docs and Postman generation. Iterate $spec->paths to emit a markdown reference page or a Postman collection. Since parameters, request bodies, and responses are all typed objects, you get description, schema->type, and required fields without regex-parsing YAML.
realpath() is not optional. Relative $refs (./components/User.yaml) resolve against the file's directory, computed from the path you pass in. Pass a relative path and external refs break silently or throw. Always realpath($path) for local files.$spec->validate() only catches structural errors (wrong types, missing required fields, misplaced keys). Full JSON-Schema validation against the OpenAPI 3.0 schema lives only in the CLI (vendor/bin/php-openapi validate), which pulls in jsonrainbow/json-schema. Use the CLI in CI and validate() as a fast pre-check.json_encode($spec) — it won't produce what you expect. Always go through Writer::writeToJson() / writeToYaml(). Likewise, count($spec->paths) fails because Paths implements IteratorAggregate but not Countable; use iterator_to_array() or getPaths().RESOLVE_MODE_INLINE, unresolved internal refs stay as cebe\openapi\spec\Reference instances, not the target object. Check $node instanceof Reference before dereferencing. With RESOLVE_MODE_ALL, recursive schemas intentionally stop at a depth limit and remain as Reference — don't assume full flattening.2 = validation failure, 1 = other error (unreadable file, unsupported refs), 0 = success. Wire php-openapi validate into a CI step and treat 2 differently from 1 in your pipeline.getRawSpecData() (added 1.8.0) returns the original un-parsed array — useful when you need fields the typed classes drop (e.g., vendor extensions you don't want mapped) or when the spec has non-standard keys.x-*) are accessible via $node->getExtensions() or dynamic property access on the object. This is how you attach Laravel-specific metadata (e.g., x-middleware, x-policy) and read it back in route generation.examples array vs map, nullable changes), you'll need to downconvert first — the CLI convert command won't do it for you./users/{id} has a matching parameters entry on the PathItem or Operation. Missing path params silently pass validate() but break spectator and league/openapi-psr7-validator at request time.Schema objects, but validating an instance against a Schema is out of scope — rely on league/openapi-psr7-validator or opis/json-schema for that.Writer preserves insertion order of keys, so build specs in a deterministic order (alphabetise paths, group components) to keep diffs clean. Numeric response codes (200, 404) become string keys in JSON — this is correct per spec but surprises people.SecurityRequirement quirk: before 1.8.0, referenced Security objects and SecurityRequirement arrays compiled to the wrong shape. Pin cebe/php-openapi: ^1.8 if your spec uses security schemes with references.OpenApi object (serialize once to PHP via var_export + OPcache, or bind as singleton) rather than re-reading YAML per HTTP request — especially in queue workers and Octane, where per-request bootstrapping burns CPU.How can I help you explore Laravel packages today?