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): selfandtoArray(): array(wire names inline; nulls skipped on serialize) — no reflection.omitschemas hydrate/serialize through their base class. A property or response typed as a DISCRIMINATED union hydrates through the union'sunmarshalXdispatcher, so consumers can narrow withinstanceof; its type includesarray, 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 isarray, an enum member is its backing scalar, and a date member isstring. - 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
writeOnlyproperty is optional even when required: requests and responses share one class, and a response never carries it. It is nullable= null, sofromArrayhydrates a response without it andtoArraystill sends it in a request. - The
Clientclass is NOTfinal— PHP test suites mock concrete classes (createMock(Client::class)), andfinalwould force a wrapper interface on every consumer. Model classes stayfinal. - Every parameter is its own argument, so their names share one namespace with the
arguments the method declares itself (
$body,$headers,$idempotencyKey). Build them withuniqueIdentifiers(..., { taken: … }): OpenAPI lets one operation use a name in two locations (idin 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 throughself::OPERATIONS[...], never a file-levelconst. OPcache preload keeps classes and functions in shared memory but drops file-level constants, and an autoloader never includes the file again onceClientexists — 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-basedunmarshalX(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 plainoneOf) 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 tomixed— rich list filters are the common case and losing their types loses the point of a typed SDK. It falls back tomixedonly when a member has no PHP type of its own (an inline object, an intersection,unknown), becausemixedcannot appear inside a union. Nullability is expressed as|nullin a union (PHP forbids mixing?with|) and?Tfor a single type. - Errors: exceptions ARE the error mode (
ApiError/TimeoutErrorextend\RuntimeException);errorModedoes not change the output (the generator declareserrorModes: ['throw'], soresultfails fast). - Dates:
dateType: Datetypesformat: date/date-timeas\DateTimeImmutable; hydration isnew \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 $idempotencyKeyon mutating methods. - Non-JSON success bodies (PDFs, images, octet streams) return the raw body as
string— a binary download must never degrade tovoid. - PHPDoc carries what the signature cannot. PHP's
arrayand\Generatorerase 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 anEnvelope(data,headers— coerced to int/bool/string with camelCase keys, absent/unparsable values omitted — andstatus). 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
Serversclass 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 staysservers[0]with variable defaults substituted. - Stream bodies pass through. For an untyped
multipart/form-dataoperation (isUntypedMultipartBodyfrom the toolkit: a schema that is neither an object nor a$ref) or a binary operation (isBinaryContentType), the body ismixedand the runtime sorts it out (multipartBody/binaryBody): a streamresourceis uploaded by curl in chunks (CURLOPT_UPLOAD+CURLOPT_INFILE, the method kept byCURLOPT_CUSTOMREQUEST), never read into a string, in exactly one attempt, whatever the retry policy says, because a consumed stream cannot be replayed.CURLOPT_INFILESIZEis set only when the stream is seekable ANDfstatreturns an array (the bytes left from the current position);php://input, pipes, and wrappers withoutstream_statgo chunked. Astringbody of either operation is sent raw, notjson_encoded; any other binary-operation value is stilljson_encoded; anarraybody of an untyped multipart operation keeps thetoMultipartpath. The caller'sContent-Typewins over the description's or the multipart boundary's, matched case-insensitively insendso the header is never sent twice; that rule holds for every operation. The whole-request curl timeout still applies (timeout: nulldisables 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 (iterSseover a curl_multi pump), multipart. - The runtime is hand-written in
runtime/runtime.phpin this folder (php -l-clean) and embedded at prepare time.curl_closeis never called (deprecated since PHP 8.5, no-op since 8.0). Under--runtime moduleit is written as aruntime.phpthe clientrequire_onces, with its namespace rewritten to the client's so one namespace spans both files. onSettledsees every sent call once.send()calls eachConfig->onSettledcallable, then eachConfig::$registeredOnSettledcallable, after the retry loop withoperationId,status(0 without a response),timedOut,attempts, anddurationMs, 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 toerror_log. SSE streams open outsidesend(), so they reach no hook.- Telemetry is opt-in at generation time. With
client.telemetry: onthe generator also writes<client>.telemetry.php(fromtelemetry/telemetry.phpin 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 passesnew UsageTelemetry(...)inConfig->onSettled. An SDK owner callsregister()instead, which adds the instance toConfig::$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), thedocshook 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 ownsamplehook, so the page can only show the syntax of the SDK beside it, and the layout comes fromrenderReferencePagein the authoring toolkit — reachable from an ejected copy through@redocly/client-generator. Pagination on the page is decided bypaginationRuleFor, the same helper this generator resolves pagination with.
The modify loop
- Edit this skill: state the new behavior or decision.
- Make
generators/php/match it. - Run
redocly generate-clientand inspect thegit diffof the generated output — generated files are never hand-edited.
Newer built-in versions merge in with redocly eject-generator php --update.