CommunityCoding & Developmentgithub.com

Redocly/php-generator

Design of the ejected Redocly `php` client generator. Read it, and update it, before changing generators/php/.

What is php-generator?

php-generator is a Claude Code agent skill that design of the ejected Redocly `php` client generator. Read it, and update it, before changing generators/php/.

Works with~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/Redocly/redocly-cli/tree/HEAD/packages/client-generator/eject-assets/skills/php-generator

Installed? Explore more Coding & Development skills: steipete/bluebubbles, steipete/eightctl, steipete/blucli · View all 6 →

Ask in your favorite AI

Open a new chat with this agent skill pre-loaded.

Documentation

The php generator — its skill

This file is the DESIGN of your ejected php generator (generators/php/): to change the generator, edit this skill first, then make the code match it — a diff to generators/php/ that has no covering sentence here is incomplete.

What it emits

One self-contained <stem>.php: promoted-constructor model classes, a Client with one typed method per operation, and the embedded runtime. PHP ≥ 8.1, HTTP over the curl extension — zero Composer dependencies. The namespace derives from the API title (identifierFor(title, pascal) — e.g. CafeOrders).

Design decisions that must hold

  • Models are final classes with constructor property promotion, required parameters first, optionals nullable = null. Hydration is compile-time generated per class: fromArray(array $data): self and toArray(): array (wire names inline; nulls skipped on serialize) — no reflection. omit schemas hydrate/serialize through their base class. A property or response typed as a DISCRIMINATED union hydrates through the union's unmarshalX dispatcher, so consumers can narrow with instanceof; its type includes array, because a value with an unknown discriminator stays the raw array. Any other union stays the decoded JSON, so its type is that of the JSON: a class member is array, an enum member is its backing scalar, and a date member is string.
  • The file passes PHPStan level 5. It lands in the user's own project, so their analyser reads it. The operations table is a constant PHPStan knows exactly, so a method reads $op['security'] only when the table has that entry, and passes [] otherwise.
  • A writeOnly property is optional even when required: requests and responses share one class, and a response never carries it. It is nullable = null, so fromArray hydrates a response without it and toArray still sends it in a request.
  • The Client class is NOT final — PHP test suites mock concrete classes (createMock(Client::class)), and final would force a wrapper interface on every consumer. Model classes stay final.
  • Every parameter is its own argument, so their names share one namespace with the arguments the method declares itself ($body, $headers, $idempotencyKey). Build them with uniqueIdentifiers(..., { taken: … }): OpenAPI lets one operation use a name in two locations (id in the path AND in the query), and PHP rejects a redefined parameter outright. The wire name is untouched, so the request is unchanged.
  • The operation map is a class constant, Client::OPERATIONS, read through self::OPERATIONS[...], never a file-level const. OPcache preload keeps classes and functions in shared memory but drops file-level constants, and an autoloader never includes the file again once Client exists — so a file-level map is undefined on every request after the first. A class constant is stored with the class.
  • Naming: classes PascalCase, properties/methods camelCase via identifierFor(..., RESERVED_WORDS.php); reserved words get a trailing underscore.
  • Enums are native backed enums (string/int); other scalars stay aliases. Discriminated unions are match-based unmarshalX(array $data) dispatchers. Each arm hydrates its mapping target the way a property of that type does, so a target with no class of its own (a plain oneOf) stays a raw array; allOf is flattened.
  • Unions keep their types where PHP 8.1 can express them. A union of scalars, enums, classes, or arrays becomes a native union type (int|string, string|array) rather than collapsing to mixed — rich list filters are the common case and losing their types loses the point of a typed SDK. It falls back to mixed only when a member has no PHP type of its own (an inline object, an intersection, unknown), because mixed cannot appear inside a union. Nullability is expressed as |null in a union (PHP forbids mixing ? with |) and ?T for a single type.
  • Errors: exceptions ARE the error mode (ApiError/TimeoutError extend \RuntimeException); errorMode does not change the output (the generator declares errorModes: ['throw'], so result fails fast).
  • Dates: dateType: Date types format: date/date-time as \DateTimeImmutable; hydration is new \DateTimeImmutable(...) and serialization formats with \DateTimeInterface::ATOM (date-time) or 'Y-m-d' (date), including for query parameters.
  • Method arguments: required path params positional, JSON body next, optional query params as nullable NAMED arguments, then ?array $headers, and ?string $idempotencyKey on mutating methods.
  • Non-JSON success bodies (PDFs, images, octet streams) return the raw body as string — a binary download must never degrade to void.
  • PHPDoc carries what the signature cannot. PHP's array and \Generator erase their element type, so a docblock states it: @return Customer[] for collection returns and @return \Generator<int, Customer> on <op>Pages()/<op>Items(). Static analysis and readers go by these; a hydrated return with no annotation looks untyped.
  • Response headers: an operation that DECLARES success-response headers gains a <op>WithHeaders() variant returning an Envelope (data, headers — coerced to int/bool/string with camelCase keys, absent/unparsable values omitted — and status). Operations without declared headers get no variant, and the base method stays body-only (PHP cannot vary a return type on a flag).
  • Servers: when the description declares servers, a Servers class is emitted with one static method per server; server VARIABLES become named string arguments defaulting to the spec's defaults (Servers::production(organizationId: 'org_x')), so templated base URLs need no manual string building. The client's baked default stays servers[0] with variable defaults substituted.
  • Stream bodies pass through. For an untyped multipart/form-data operation (isUntypedMultipartBody from the toolkit: a schema that is neither an object nor a $ref) or a binary operation (isBinaryContentType), the body is mixed and the runtime sorts it out (multipartBody / binaryBody): a stream resource is uploaded by curl in chunks (CURLOPT_UPLOAD + CURLOPT_INFILE, the method kept by CURLOPT_CUSTOMREQUEST), never read into a string, in exactly one attempt, whatever the retry policy says, because a consumed stream cannot be replayed. CURLOPT_INFILESIZE is set only when the stream is seekable AND fstat returns an array (the bytes left from the current position); php://input, pipes, and wrappers without stream_stat go chunked. A string body of either operation is sent raw, not json_encoded; any other binary-operation value is still json_encoded; an array body of an untyped multipart operation keeps the toMultipart path. The caller's Content-Type wins over the description's or the multipart boundary's, matched case-insensitively in send so the header is never sent twice; that rule holds for every operation. The whole-request curl timeout still applies (timeout: null disables it). Typed multipart bodies are unaffected.
  • Parity surface: auth, retries with Retry-After + jittered backoff, per-attempt curl timeouts, middleware callables, pagination (<op>Pages() / <op>Items() as \Generators), SSE (iterSse over a curl_multi pump), multipart.
  • The runtime is hand-written in runtime/runtime.php in this folder (php -l-clean) and embedded at prepare time. curl_close is never called (deprecated since PHP 8.5, no-op since 8.0). Under --runtime module it is written as a runtime.php the client require_onces, with its namespace rewritten to the client's so one namespace spans both files.
  • onSettled sees every sent call once. send() calls each Config->onSettled callable, then each Config::$registeredOnSettled callable, after the retry loop with operationId, status (0 without a response), timedOut, attempts, and durationMs, before it throws or returns. Every hook runs. The first hook error is thrown for a successful call; on a failed call, the original error wins and the hook error goes to error_log. SSE streams open outside send(), so they reach no hook.
  • Telemetry is opt-in at generation time. With client.telemetry: on the generator also writes <client>.telemetry.php (from telemetry/telemetry.php in this folder, embedded at prepare time) beside the client, in both runtime modes, with the client's namespace and the generator version baked in. The client file does not change. The application requires it and passes new UsageTelemetry(...) in Config->onSettled. An SDK owner calls register() instead, which adds the instance to Config::$registeredOnSettled, so every client counts its calls; UsageTelemetry::flushRegistered() flushes each registered instance. PHP has no timers: a call that finds its window elapsed sends it first, and a shutdown function sends the open window. Constants live on the class, because OPcache preloading drops file-level constants. The class name is reserved for every SDK, because the file shares the namespace with the models.
  • Authored ONLY with the neutral toolkit — the dogfooding guard fails otherwise.

Migrating from a service-based SDK

  • Per-resource services ($client->customers()->get($id)) map to flat methods named after operationIds ($client->getCustomer($id)); optional query params keep their named-argument style (filter:, sort:, limit:).

  • Collection wrappers exposing pagination RESPONSE HEADERS (getTotalItems(), getLimit()) map to the <op>WithHeaders() envelope (->headers['paginationTotal']); plain iteration maps to <op>Items() / <op>Pages() generators.

  • Dedicated validation-exception classes exposing field errors map to catch (ApiError $e) + $e->status === 422 + the decoded $e->body.

  • Session/bearer token flows map to auth: ['bearer' => $tokenProvider] with a callable — resolved per request, so refresh needs no client rebuild.

  • It documents itself. With client.docs (or --docs), the docs hook writes <stem>.php.md: the security schemes, then one section per operation with its parameters, body, response type, and behavior notes. The call snippets come from this generator's own sample hook, so the page can only show the syntax of the SDK beside it, and the layout comes from renderReferencePage in the authoring toolkit — reachable from an ejected copy through @redocly/client-generator. Pagination on the page is decided by paginationRuleFor, the same helper this generator resolves pagination with.

The modify loop

  1. Edit this skill: state the new behavior or decision.
  2. Make generators/php/ match it.
  3. Run redocly generate-client and inspect the git diff of the generated output — generated files are never hand-edited.

Newer built-in versions merge in with redocly eject-generator php --update.

Individual skills in this repo

This repo contains 2 individual skills — each has its own dedicated page.

Related Skills