openapi-tools / generator-psr-15-webhook-middleware
PSR-15 WebHook Middleware generator
Package info
github.com/php-openapi-tools/generator-psr-15-webhook-middleware
Language:Makefile
pkg:composer/openapi-tools/generator-psr-15-webhook-middleware
Fund package maintenance!
Requires
- php: ^8.4
- ext-json: ^8.4
- devizzent/cebe-php-openapi: ^1.1.5
- league/openapi-psr7-validator: ^0.21.0
- nikic/php-parser: ^5.0
- openapi-tools/contract: ^0.1.0
- openapi-tools/generator-utils: ^0.1.0
- openapi-tools/representation: ^0.1.0
- openapi-tools/utils: ^0.1.0
- psr/http-message: ^1 || ^2 || ^3
- psr/http-server-handler: ^1 || ^2
- psr/http-server-middleware: ^1.0
Requires (Dev)
- dantleech/docbot: ^0.3.0
- openapi-tools/configuration: ^0.1.0
- openapi-tools/gatherer: ^0.1.0
- openapi-tools/test-data: ^0.1.0
- wyrihaximus/async-test-utilities: ^13.5.1
- wyrihaximus/makefiles: ^0.13.3
This package is auto-updated.
Last update: 2026-08-27 19:01:55 UTC
README
FileGenerator for OpenAPI Tools that emits a PSR-15 webhook middleware stack: an enum-backed resolver, custom hydrator, and public middleware. When entryPoints.webHookMiddleware is enabled, it replaces the public WebHooks entry point and EventSauce webhook hydrators for that mode.
Installation
composer require openapi-tools/generator-psr-15-webhook-middleware
Where it fits
This package runs after gatherer has built a representation and generator-schema has emitted payload schema classes. Register WebHookMiddleware when middleware mode is enabled; set includeWebHookHydrators: false on generator-hydrator so webhook hydration is owned here instead.
flowchart LR spec[OpenAPI spec] --> gatherer[Gatherer] gatherer --> rep[Representation] rep --> schema[Schema generator] schema --> wh[WebHook middleware generator] wh --> middleware[WebHookMiddleware] wh --> resolver[Internal WebHooks + Event enum]Loading
Example
The snippet below is generated by running the webhook middleware generator against a real OpenAPI input when you run make generate-readme. The example driver lives in etc/docs/README.php.
Input — OpenAPI webhooks (example-input.yaml):
openapi: "3.1.0" info: version: 1.0.0 title: Swagger Petstore license: name: MIT servers: - url: http://api.example.com/v1/ webhooks: healthCheck: post: summary: Health check webhook description: A health check event sent to verify a webhook configuration. operationId: health-check/received parameters: - name: X-Petstore-Event in: header example: healthCheck schema: type: string requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/healthCheck" responses: "200": description: Return a 200 status to indicate that the data was received successfully inventoryUpdate: post: summary: Inventory update webhook description: An inventory update event sent when stock levels change. operationId: inventory/update parameters: - name: X-Petstore-Event in: header example: inventoryUpdate schema: type: string requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/inventoryUpdate" responses: "200": description: Return a 200 status to indicate that the data was received successfully petLifecycle: post: summary: Pet lifecycle webhook description: A pet lifecycle event resolved by an `eventType` discriminator in the payload. operationId: pet-lifecycle/received requestBody: required: true content: application/json: schema: oneOf: - $ref: "#/components/schemas/adoptedPet" - $ref: "#/components/schemas/surrenderedPet" discriminator: propertyName: eventType mapping: adopted: "#/components/schemas/adoptedPet" surrendered: "#/components/schemas/surrenderedPet" responses: "200": description: Return a 200 status to indicate that the data was received successfully storePolicy: post: summary: Store policy webhook description: A store policy event resolved by an `action` discriminator in the payload. operationId: store-policy/received requestBody: required: true content: application/json: schema: oneOf: - $ref: "#/components/schemas/webhook-store-policy-created" - $ref: "#/components/schemas/webhook-store-policy-deleted" discriminator: propertyName: action mapping: created: "#/components/schemas/webhook-store-policy-created" deleted: "#/components/schemas/webhook-store-policy-deleted" responses: "200": description: Return a 200 status to indicate that the data was received successfully components: schemas: healthCheck: type: object required: - message properties: message: type: string inventoryUpdate: type: object required: - sku - quantity properties: sku: type: string quantity: type: integer adoptedPet: type: object required: - eventType - name properties: eventType: type: string enum: [adopted] name: type: string surrenderedPet: type: object required: - eventType - name properties: eventType: type: string enum: [surrendered] name: type: string webhook-store-policy-created: title: store policy created event type: object required: - action - policy - store properties: action: type: string enum: [created] franchise: $ref: "#/components/schemas/franchise-webhooks" registration: $ref: "#/components/schemas/store-registration" chain: $ref: "#/components/schemas/chain-webhooks" store: $ref: "#/components/schemas/store-webhooks" policy: $ref: "#/components/schemas/policy_rule" customer: $ref: "#/components/schemas/customer" webhook-store-policy-deleted: title: store policy deleted event type: object required: - action - policy - store properties: action: type: string enum: [deleted] franchise: $ref: "#/components/schemas/franchise-webhooks" registration: $ref: "#/components/schemas/store-registration" chain: $ref: "#/components/schemas/chain-webhooks" store: $ref: "#/components/schemas/store-webhooks" policy: $ref: "#/components/schemas/policy_rule" customer: $ref: "#/components/schemas/customer" franchise-webhooks: type: object properties: slug: type: string store-registration: type: object properties: id: type: integer chain-webhooks: type: object properties: name: type: string store-webhooks: type: object required: - name properties: name: type: string policy_rule: type: object required: - id properties: id: type: integer customer: type: object required: - name properties: name: type: string
Output
Running gatherer + WebHookMiddleware::generate() against the input above emits these files. The spec defines four webhooks: healthCheck and inventoryUpdate are resolved by matching headers (and field fingerprints); petLifecycle and storePolicy use an Event enum with discriminator match on eventType and action. All resolution lives in Internal\WebHook\WebHooks:
src/Internal/WebHook/Hydrator.php
<?php declare (strict_types=1); namespace ApiClients\Client\Example\Internal\WebHook; final class Hydrator { public function hydrate(string $className, array $data): object { return match ($className) { \ApiClients\Client\Example\Schema\HealthCheck::class => $this->hydrateSchemaHealthCheck($data), \ApiClients\Client\Example\Schema\InventoryUpdate::class => $this->hydrateSchemaInventoryUpdate($data), \ApiClients\Client\Example\Schema\AdoptedPet::class => $this->hydrateSchemaAdoptedPet($data), \ApiClients\Client\Example\Schema\SurrenderedPet::class => $this->hydrateSchemaSurrenderedPet($data), \ApiClients\Client\Example\Schema\WebhookStorePolicyCreated::class => $this->hydrateSchemaWebhookStorePolicyCreated($data), \ApiClients\Client\Example\Schema\WebhookStorePolicyDeleted::class => $this->hydrateSchemaWebhookStorePolicyDeleted($data), default => throw new \RuntimeException('Unknown webhook payload class'), }; } private function hydrateSchemaHealthCheck(array $data): \ApiClients\Client\Example\Schema\HealthCheck { return new \ApiClients\Client\Example\Schema\HealthCheck(message: $data['message']); } private function hydrateSchemaInventoryUpdate(array $data): \ApiClients\Client\Example\Schema\InventoryUpdate { return new \ApiClients\Client\Example\Schema\InventoryUpdate(sku: $data['sku'], quantity: $data['quantity']); } private function hydrateSchemaAdoptedPet(array $data): \ApiClients\Client\Example\Schema\AdoptedPet { return new \ApiClients\Client\Example\Schema\AdoptedPet(eventType: $data['eventType'], name: $data['name']); } private function hydrateSchemaSurrenderedPet(array $data): \ApiClients\Client\Example\Schema\SurrenderedPet { return new \ApiClients\Client\Example\Schema\SurrenderedPet(eventType: $data['eventType'], name: $data['name']); } private function hydrateSchemaWebhookStorePolicyCreated(array $data): \ApiClients\Client\Example\Schema\WebhookStorePolicyCreated { return new \ApiClients\Client\Example\Schema\WebhookStorePolicyCreated(action: $data['action'], franchise: $data['franchise'] === null ? null : $this->hydrate(\ApiClients\Client\Example\Schema\FranchiseWebhooks::class, $data['franchise']), registration: $data['registration'] === null ? null : $this->hydrate(\ApiClients\Client\Example\Schema\StoreRegistration::class, $data['registration']), chain: $data['chain'] === null ? null : $this->hydrate(\ApiClients\Client\Example\Schema\ChainWebhooks::class, $data['chain']), store: $this->hydrate(\ApiClients\Client\Example\Schema\StoreWebhooks::class, $data['store']), policy: $this->hydrate(\ApiClients\Client\Example\Schema\PolicyRule::class, $data['policy']), customer: $data['customer'] === null ? null : $this->hydrate(\ApiClients\Client\Example\Schema\Customer::class, $data['customer'])); } private function hydrateSchemaWebhookStorePolicyDeleted(array $data): \ApiClients\Client\Example\Schema\WebhookStorePolicyDeleted { return new \ApiClients\Client\Example\Schema\WebhookStorePolicyDeleted(action: $data['action'], franchise: $data['franchise'] === null ? null : $this->hydrate(\ApiClients\Client\Example\Schema\FranchiseWebhooks::class, $data['franchise']), registration: $data['registration'] === null ? null : $this->hydrate(\ApiClients\Client\Example\Schema\StoreRegistration::class, $data['registration']), chain: $data['chain'] === null ? null : $this->hydrate(\ApiClients\Client\Example\Schema\ChainWebhooks::class, $data['chain']), store: $this->hydrate(\ApiClients\Client\Example\Schema\StoreWebhooks::class, $data['store']), policy: $this->hydrate(\ApiClients\Client\Example\Schema\PolicyRule::class, $data['policy']), customer: $data['customer'] === null ? null : $this->hydrate(\ApiClients\Client\Example\Schema\Customer::class, $data['customer'])); } }
src/Internal/WebHook/Event.php
<?php declare (strict_types=1); namespace ApiClients\Client\Example\Internal\WebHook; enum Event { case HealthCheck; case InventoryUpdate; case AdoptedPet; case SurrenderedPet; case WebhookStorePolicyCreated; case WebhookStorePolicyDeleted; }
src/Internal/WebHook/WebHooks.php
<?php declare (strict_types=1); namespace ApiClients\Client\Example\Internal\WebHook; final class WebHooks { public function __construct(private readonly \League\OpenAPIValidation\Schema\SchemaValidator $requestSchemaValidator, private readonly \ApiClients\Client\Example\Internal\WebHook\Hydrator $hydrator) { } public function resolve(array $headers, array $data): \ApiClients\Client\Example\Schema\HealthCheck|\ApiClients\Client\Example\Schema\InventoryUpdate|\ApiClients\Client\Example\Schema\AdoptedPet|\ApiClients\Client\Example\Schema\SurrenderedPet|\ApiClients\Client\Example\Schema\WebhookStorePolicyCreated|\ApiClients\Client\Example\Schema\WebhookStorePolicyDeleted { $headers = (static function (array $headers): array { $loweredHeaders = []; foreach ($headers as $key => $value) { $loweredHeaders[strtolower($key)] = $value; } return $loweredHeaders; })($headers); return match ($this->detectDiscriminatedEvent($data)) { Event::AdoptedPet => $this->validatedHydrate(\ApiClients\Client\Example\Schema\AdoptedPet::class, $data), Event::SurrenderedPet => $this->validatedHydrate(\ApiClients\Client\Example\Schema\SurrenderedPet::class, $data), Event::WebhookStorePolicyCreated => $this->validatedHydrate(\ApiClients\Client\Example\Schema\WebhookStorePolicyCreated::class, $data), Event::WebhookStorePolicyDeleted => $this->validatedHydrate(\ApiClients\Client\Example\Schema\WebhookStorePolicyDeleted::class, $data), default => $this->resolveByHeaders($headers, $data), }; } private function detectDiscriminatedEvent(array $data): ?Event { if (array_key_exists('eventType', $data)) { $event = match ($data['eventType']) { 'adopted' => Event::AdoptedPet, 'surrendered' => Event::SurrenderedPet, default => null, }; if ($event instanceof Event) { return $event; } } if (array_key_exists('action', $data)) { $event = match ($data['action']) { 'created' => Event::WebhookStorePolicyCreated, 'deleted' => Event::WebhookStorePolicyDeleted, default => null, }; if ($event instanceof Event) { return $event; } } return null; } private function resolveByHeaders(array $headers, array $data): \ApiClients\Client\Example\Schema\HealthCheck|\ApiClients\Client\Example\Schema\InventoryUpdate { $error = new \RuntimeException('No webhook matching given headers and data'); try { if ($headers['x-petstore-event'] === 'healthCheck' && array_key_exists('message', $data)) { return $this->validatedHydrate(\ApiClients\Client\Example\Schema\HealthCheck::class, $data); } } catch (\Throwable $throwable) { $error = $throwable; } try { if ($headers['x-petstore-event'] === 'inventoryUpdate' && array_key_exists('sku', $data) && array_key_exists('quantity', $data)) { return $this->validatedHydrate(\ApiClients\Client\Example\Schema\InventoryUpdate::class, $data); } } catch (\Throwable $throwable) { $error = $throwable; } throw $error; } private function validatedHydrate(string $className, array $data): object { $this->requestSchemaValidator->validate($data, \cebe\openapi\Reader::readFromJson($className::SCHEMA_JSON, \cebe\openapi\spec\Schema::class)); return $this->hydrator->hydrate(className::class, $data); } }
src/WebHookMiddleware.php
<?php declare (strict_types=1); namespace ApiClients\Client\Example; use OpenAPITools\Contract\WebHookHandlerInterface; use \ApiClients\Client\Example\Internal\WebHook\InvalidWebHookRequestException; use \ApiClients\Client\Example\Internal\WebHook\WebHooks; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; final readonly class WebHookMiddleware implements Psr\Http\Server\MiddlewareInterface { public function __construct(private readonly \ApiClients\Client\Example\Internal\WebHook\WebHooks $webHooks, private readonly \OpenAPITools\Contract\WebHookHandlerInterface $handler, private readonly array $paths = ['/webhook']) { } public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { if ($this->paths !== [] && !in_array($request->getUri()->getPath(), $this->paths, true)) { return $handler->handle($request); } $body = (string) $request->getBody(); if ($body === '') { throw new InvalidWebHookRequestException('Missing webhook request body'); } $data = json_decode($body, true); if (is_array($data) !== true) { throw new InvalidWebHookRequestException('Invalid webhook request body'); } $headers = []; foreach ($request->getHeaders() as $name => $values) { $headers[strtolower($name)] = $values[0]; } try { $payload = $this->webHooks->resolve($headers, $data); } catch (\Throwable $throwable) { throw new InvalidWebHookRequestException('Failed to resolve webhook', 0, $throwable); } return $this->handler->handle($payload); } }
For each payload variant in the spec, the generator emits:
| Class | Visibility |
|---|---|
WebHookMiddleware |
Public |
Internal\WebHook\WebHooks |
Internal — enum match for discriminators, header matching fallback otherwise |
Internal\WebHook\Event |
Internal — one enum case per payload variant |
Internal\WebHook\Hydrator |
Internal |
Internal\WebHook\InvalidWebHookRequestException |
Internal |
Schema classes (Schema\Ping, Schema\Push, …) are still emitted by generator-schema.
Resolution order
For each webhook delivery, generated code resolves the payload in this order:
- Headers — match declared webhook header parameters (e.g.
X-Event-Type) - Discriminator — when the spec defines
discriminator, match$data[$propertyName] - Field fingerprint — required fields must be present in
$data - Validation —
SchemaValidatoragainst generatedSCHEMA_JSON - Hydration —
Internal\WebHook\Hydratormaps arrays to readonly schema objects (no EventSauce)
Behaviour
- Path filter: when
$pathsis non-empty, only matching request paths are treated as webhooks; all other requests pass through unchanged. - Empty paths: when
$pathsis[], every request is treated as a potential webhook (intended for local development and testing). - Strict: invalid JSON, missing body, or failed resolve throws
InvalidWebHookRequestException. - Happy path: on success, returns
$this->handler->handle($payload)directly.
Usage in openapi-client-generator
entryPoints: webHookMiddleware: true
Or with explicit paths (overridable at runtime via the middleware constructor):
entryPoints: webHookMiddleware: paths: - /webhook - /hooks/github
When middleware mode is enabled, webHooks: true is implied but the public WebHooks / WebHook generators are not registered.
Implement OpenAPITools\Contract\WebHookHandlerInterface to handle resolved payloads:
final readonly class MyWebHookHandler implements WebHookHandlerInterface { public function handle(object $payload): ResponseInterface { return match ($payload::class) { Ping::class => $this->ping($payload), default => new EmptyResponse(404), }; } }
Wire the middleware in your HTTP application:
$middleware = new WebHookMiddleware( new Internal\WebHook\WebHooks($requestSchemaValidator, new Internal\WebHook\Hydrator()), new MyWebHookHandler(), paths: ['/webhook'], );
In a full client package, WebHookMiddleware is wired into the generator run loop alongside other FileGenerator implementations. A minimal direct invocation looks like this:
use OpenAPITools\Generator\PSR15\WebHook\WebHookMiddleware; use PhpParser\BuilderFactory; $generator = new WebHookMiddleware(new BuilderFactory(), defaultPaths: ['/webhook']); foreach ($generator->generate($package, $representation->namespace($package->namespace)) as $file) { // $file->pathPrefix — e.g. "src" // $file->fqcn — e.g. "WebHookMiddleware" // $file->contents — PhpParser Node }
See openapi-tools/generator for a complete package configuration.
Related packages
| Package | Relationship |
|---|---|
contract |
FileGenerator, WebHookHandlerInterface, and Package interfaces |
representation |
Input model consumed by this generator |
gatherer |
Builds the representation from OpenAPI |
generator-schema |
Emits payload schema classes this generator hydrates |
generator-hydrator |
Disable webhook hydrators when this package is active |
generator-utils |
AST builders used by this generator |
generator |
CLI and run loop that orchestrates all generators |
Contributing
Please see CONTRIBUTING for details.
License
The MIT License (MIT)
Copyright (c) 2026 Cees-Jan Kiewiet
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.