ADR 0012: Symfony HttpClient migration and x-fetch-mode (GET/HEAD only)¶
- Status: Accepted
- Codified: 2026-09 (8.0)
- Origin: issues #367 (fetch strategies), #1036 (multipart per-part filename/Content-Type), #789 (applyServerPlugins), #844 (exception response access)
Context¶
The OpenAPI runtime and generated clients are built on PSR-18 + PSR-7 + HTTPlug
plugins (PluginClient, AddHostPlugin/AddPathPlugin, AuthenticationRegistry
as an HTTPlug plugin, MultipartStreamBuilder). This stacks three abstraction
layers over a transport, forces PSR-7 body juggling on generated code, and makes
per-operation fetch strategies impossible: executeEndpoint() always performs a
blocking request → parse cycle, and the legacy $fetch/FETCH_OBJECT parameter
of parseResponse() is vestigial (never branched on).
Meanwhile users need strategies per GET/HEAD operation: lazy (defer send, parse
on access), eager (today's blocking behavior), preload (batch-send, parse on
access). Mutating verbs must always remain eager and must never accept the
extension, silently or otherwise (ADR 0002: clean, complete pre-generation errors).
Decision¶
- Symfony-native transport. Remove PSR-18, PSR-7, PSR-17 and HTTPlug from
the OpenAPI runtime and generated clients. Generated clients take a
Symfony\Contracts\HttpClient\HttpClientInterfaceand aSerializerInterface(2-arg constructor); bodies arestring|resource|array. Addsymfony/http-client ^6.4 || ^7.0 || ^8.0; droppsr/*,php-http/*,nyholm/psr7. - Plugins become
HttpClientInterfacedecorators. Decorator contract:__invoke(HttpClientInterface $inner): HttpClientInterface. Shipped decorators (sameClient\Plugin/FQCNs as before where possible): ServerUrlHttpClientrewrites the URL to the spec server URL (scheme, host, port, base path prepended). It does not usebase_uri: verified against symfony/http-client, RFC 3986 resolution drops the base path for absolute-path request URLs (/pets+base_uri=…/api/v1→/pets), while HTTPlug'sAddPathPluginblindly prepends (/api/v1/pets). Jane's generated clients always use absolute-path URIs, so only explicit prepending reproduces the oldAddHostPlugin+AddPathPluginpair.AuthenticationRegistryis a decorator factory that reads authentication scopes from$options['extra'](the only option bag Symfony guarantees to pass through and ignore if unsupported), applies matchingAuthenticationPlugins and strips the marker key before forwarding.$additionalPluginsincreate()is redefined as a list of decorator factories (callable(HttpClientInterface): HttpClientInterface), applied left-to-right in the generatedcreate()via aforeachloop.AuthenticationPluginchanges todecorate(string $method, string $url, array &$options): void+getScope(): string(header/query auth only; no body access).x-fetch-mode, GET/HEAD only. Per-operation OpenAPI extension with valueslazy(default) |eager|preload. GET/HEAD resolution precedence:x-fetch-mode>default-fetch-modegeneration option >lazy. Every other verb is hardcodedeager;default-fetch-modenever applies to it.- Enforced at generation time per ADR 0002: a
FetchModeValidatorin each ofOpenApi{2,3,31}/SchemaParser/registered onSchemaParser::validateSchema()walks the raw document'spaths.{path}.{method}and collects every violation (x-fetch-modeon a non-get/headmethod; or a value that is not exactlylazy|eager|preload) with an RFC 6901 JSON pointer and a fix hint, rendered by the existing[ERROR]block machinery. - Lazy ghost proxies and client surface.
executeEndpoint()branches on the endpoint's fetch mode:eager= request + parse now (today's semantics, call-time exceptions);lazy/preload= return a PHP native lazy ghost proxy (ReflectionClass::newLazyGhost, PHP >= 8.4) of the endpoint's success model class.lazysends nothing until the proxy is first accessed;preloadregisters the request immediately (the response object is created at call time) and parses on first access. An endpoint exposesgetTargetClass(): ?string— the success model class when every 2xx / default branch denormalizes to a single model class,nullotherwise ( mutating verbs alwaysnull). The ghost initializer runs the endpoint's ownprocessEndpoint()+parseResponse()(never a fixed model class, preserving per-status model/exception mapping) and a documented 4xx/5xx throws on first access; non-ghostable responses (arrays, maps, scalars, no-content, several models / content types) degrade deferred modes to eager.Jane\Component\OpenApiRuntime\Client\GhostFactorycentralizes availability check, creation and forced initialization; state introspection uses the PHP reflection API (ReflectionClass::isUninitializedLazyObject()/initializeLazyObject()).Client::stream()keeps batching raw responses only;executeRawEndpoint()keeps returning the response. - No-throw parity with PSR-18 is achieved by consuming with
$throw = false(getHeaders(false),getContent(false),toArray(false)): the historical'throw' => falserequest option does not exist in symfony/http-client 8.x / contracts 3.7 — throwing moved to per-methodbool $throw = trueparameters.getStatusCode()never throws on 3/4/5xx (transport errors only). Generated code and the runtime therefore always passfalse; raw responses returned to users keep Symfony's default ($throw = trueon explicit reads). - Multipart moves to an internal runtime builder (boundary, per-part
Content-Dispositionwith filename fallback to the property name for in-memory values, per-partContent-Typefromencoding) returning astringbody — GH#1036-compatible — replacingMultipartStreamBuilder.BaseEndpointdropsStreamFactoryInterface; file-path bodies becomefopen($body, 'rb'). - Legacy removal.
FETCH_OBJECTconstant,$fetchparameter splice inOperationGenerator, the vestigial$fetchModeargument ofparseResponse(), and the deadInvalidFetchModeException(zero throw sites) are removed in 8.0. - Redirects keep Symfony's default (
max_redirects: 20), matching the historical HTTPlug behavior users got in practice.
Spike evidence (recorded 2026-09, symfony/http-client 8.1)¶
| Hypothesis | Result |
|---|---|
base_uri drops its path for absolute-path request URLs |
Confirmed (source HttpClientTrait::resolveUrl() + live loopback server): /pets + base_uri=http://h/api/v1 → /pets; empty URL → /api/v1; relative pets → /api/pets (RFC merge). AddPathPlugin prepends blindly. Justifies ServerUrlHttpClient. |
extra request option passes through |
Confirmed (contracts HttpClientInterface + live): documented as "additional options that can be ignored if unsupported, unlike regular options"; unknown regular options must throw, extra must not. Not surfaced in getInfo() (forward-only) — user_data is the read-back channel. |
Unconsumed request()s progress in the background |
Refined: requests hit the wire only at the first multiplexer tick (loopback receipts: 3 requests issued, server saw nothing for 1s; all three arrived together at first consume and completed in one 0.6s window). Once ticking, all in-flight requests of a client progress concurrently; idle periods make no progress; GC aborts unconsumed transfers. preload therefore = immediate registration + concurrent dispatch at the first await()/stream()/parse. |
Consequences¶
- Generated clients and the runtime lose all
psr/*/php-http/*/nyholm/psr7dependencies; users on PSR-18 clients must adapt (8.0 BC break). - Custom
Endpointimplementers must addgetFetchMode()andgetTargetClass()and drop theparseResponse()$fetchModeargument; customAuthenticationPluginimplementers migrateauthenticate()todecorate(). - Mutating operations with
x-fetch-modenow fail generation with clean, complete[ERROR]blocks instead of being silently ignored. preloadenables client-side batching (stream()/await()) with buffered responses by default (memory ∝ payload × parallelism);buffer => falseis the documented escape hatch and imposes consume-in-order.- Lazy defers serialization and auth to send time (first tick).
- Fixture churn is expected and scoped to the HTTP/fetch contract (ADR 0004).