Your SDK requires guzzlehttp/guzzle: ^7.0. A customer whose application has moved to Guzzle 8
cannot install it, because Composer cannot satisfy both majors. A customer on Symfony can install
it and gets a second HTTP stack, one that ignores the proxy, timeouts, and logging they configured
on the first. In both cases your SDK has chosen their HTTP client for them.
What you get
You will end up with an SDK that sends through the caller’s PSR-18 client, finds an installed one when given none, and says what to install when nothing is there. This is for you if you publish a PHP client library.
Short answer
Type the SDK’s constructor against Psr\Http\Client\ClientInterface and the PSR-17 request and
stream factories, require only the PSR packages and php-http/discovery, and fall back to
Psr18ClientDiscovery::find() when the caller passes nothing. Catch discovery’s
NotFoundException in the constructor and throw one that names the install command. Check the
status code yourself, because a PSR-18 client returns 4xx and 5xx as ordinary responses.
You will need
PHP 8.1 or later, Composer, and one PSR-18 implementation as a development dependency so the SDK’s
own tests can build requests. Verified 2026-10-15 against PHP 8.3.6, php-http/discovery 1.20.0
and Guzzle 8.2.0. The interfaces are PSR-18 for the client
and PSR-17 for the factories that build requests and
bodies. How the constructor takes its other options, such as the base URL and credentials,
is a separate design question this page assumes is settled.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
OpenAPI Generator php with Guzzle | You generate from a spec and your callers already run Guzzle | Every caller installs Guzzle, so a different Guzzle major in their application is a Composer conflict | Callers run Symfony or another major; the generator’s psr-18 library option is marked beta |
| PSR-18 with php-http/discovery | A hand-written SDK used inside frameworks that already own an HTTP client | A longer first install, because a beginner has to add an implementation before any call works | Your users are standalone scripts, where a bundled client saves a support ticket |
| Stripe PHP curl client | An SDK that must run on any host with ext-curl and no other package | Its own client interface, swapped through a static ApiRequestor::setHttpClient, so PSR-18 middleware never sees a call | Callers expect their client’s logging, retries, and proxy settings to apply |
| Symfony HttpClient Psr18Client | You want PSR-18 types and a default that works with no extra install | Every caller installs symfony/http-client and its contracts, even callers who pass their own client | Your callers mostly run Guzzle, and would carry two HTTP stacks |
Binding to Guzzle, as OpenAPI Generator’s default does, is the shortest path for a caller who
already has Guzzle and a version conflict for everyone else. Stripe’s client needs nothing but
cURL, at the cost of an interface no framework knows: its
ClientInterface
takes a method, a URL and header strings, and returns an array. PSR-18 with discovery costs a beginner one
extra composer require, and in exchange the caller’s client, with its middleware and timeouts,
carries every request.
Depend on the interfaces
The SDK’s composer.json requires the three PSR packages and discovery, pins Guzzle for its own
tests only, and names two implementations under suggest.
"require": {
"php": ">=8.1",
"php-http/discovery": "^1.19",
"psr/http-client": "^1.0",
"psr/http-factory": "^1.0",
"psr/http-message": "^1.1 || ^2.0"
},
"require-dev": {
"guzzlehttp/guzzle": "8.2.0"
},
"suggest": {
"guzzlehttp/guzzle": "A PSR-18 client with PSR-17 factories in one package (7.0 or later)",
"symfony/http-client": "Its Psr18Client, together with nyholm/psr7 for the PSR-17 factories"
},
The allow-plugins entry further down sets php-http/discovery to false. Discovery ships a
Composer plugin that can install a missing implementation, which the
discovery documentation
tells a library to turn off once its development dependencies are in place.
Take the client in the constructor
The constructor accepts the client and both factories, and falls back to discovery for each one the caller leaves out.
public function __construct(
#[\SensitiveParameter] private string $apiKey,
?ClientInterface $http = null,
?RequestFactoryInterface $requests = null,
?StreamFactoryInterface $streams = null,
private string $baseUri = 'https://api.meterco.example/v1',
) {
$this->baseUri = rtrim($baseUri, '/');
try {
$this->http = $http ?? Psr18ClientDiscovery::find();
$this->requests = $requests ?? Psr17FactoryDiscovery::findRequestFactory();
$this->streams = $streams ?? Psr17FactoryDiscovery::findStreamFactory();
} catch (NotFoundException $e) {
throw new MissingHttpClient(
'meterco/sdk needs a PSR-18 client and PSR-17 factories, and none are installed. '
. 'Run "composer require guzzlehttp/guzzle", or "composer require symfony/http-client nyholm/psr7", '
. 'or pass your own to the MeterClient constructor.',
previous: $e,
);
}
}
Discovery runs in the constructor, not at the first request. A missing client then fails where the SDK is configured, usually at boot, rather than deep inside the first request a user makes.
Check the status yourself
PSR-18 says a client must not treat a well-formed response as an error, so a 404 or a 500 comes back
from sendRequest like a 200.1 Only a request that could not be sent, or a response that could
not be parsed, throws.
// A network failure throws Psr\Http\Client\ClientExceptionInterface.
// A 3xx, 4xx or 5xx does not: PSR-18 returns it as a normal response,
// and some clients do not follow redirects.
$response = $this->http->sendRequest($request);
$status = $response->getStatusCode();
if ($status < 200 || $status > 299) {
throw ApiError::from($request, $response);
}
An SDK ported from Guzzle’s request() method loses its error handling here without a sound,
because request() throws on 4xx and sendRequest does not.
Check it worked
A recording client implements ClientInterface in a dozen lines, keeps every request, and answers
from a queue. The demonstration prints the request the SDK built and what discovery resolves in an
install that has Guzzle.
php sdk/demo.php
discovered in this install
Psr18ClientDiscovery::find() GuzzleHttp\Client
Psr17FactoryDiscovery::findRequestFactory() GuzzleHttp\Psr7\HttpFactory
the request the recording client received
POST /v1/readings HTTP/1.1
Host: api.meterco.example
Accept: application/json
Authorization: Bearer mk_test_alpha
Content-Type: application/json
{"meter":"mtr_1","kwh":12.5}
returned: {"id":"rdg_9"}
The tests assert on the recorded request, then on the failures: a dot-segment id, a 404, a network
exception, a redirect, and an install with nothing to discover. rawurlencode leaves .. as it
is, and a client resolves /v1/meters/.. to /v1/, so the SDK refuses that id itself.
php sdk/tests/test.php
ok getMeter sends the request the API reference describes
ok recordReading sends a JSON body
ok an id cannot climb out of its path segment
ok a dot-segment or empty id is refused before anything is sent
ok a base URI with a trailing slash does not double the slash
ok a 404 comes back as a response and the SDK throws ApiError
ok a network failure reaches the caller as a PSR-18 exception
ok the caller's Guzzle client runs its own middleware
ok Guzzle behind PSR-18 hands back a redirect instead of following it
ok with no client passed, discovery finds the installed one
ok with nothing discoverable, the constructor says what to install
11 cases, 0 failures
The last case empties discovery’s strategy list with ClassDiscovery::setStrategies([]), its
documented extension point, and restores it afterwards.
When it goes wrong
The application installed your SDK and nothing else, and the first request fails with a message
that recommends an adapter package. bare/ is that application.
php bare/demo.php
what php-http/discovery throws
Http\Discovery\Exception\NotFoundException
No PSR-18 clients found. Make sure to install a package providing
"psr/http-client-implementation". Example: "php-http/guzzle7-adapter".
what the SDK throws in its constructor
Meterco\Sdk\MissingHttpClient
meterco/sdk needs a PSR-18 client and PSR-17 factories, and none are installed. Run
"composer require guzzlehttp/guzzle", or "composer require symfony/http-client nyholm/psr7",
or pass your own to the MeterClient constructor.
The example in discovery’s message is an HTTPlug adapter whose
package requires Guzzle ^7.0.2 Guzzle
implements PSR-18 itself, so following that advice adds an adapter and a Guzzle major the caller may
not want. Catch NotFoundException and say what to run, as the SDK does.
The suggest field did not help either. During the install, Composer 2.8.12 printed a count of
the suggestions and a pointer to composer suggest, which shows the text:
composer suggest --working-dir=bare
meterco/sdk suggests:
- guzzlehttp/guzzle: A PSR-18 client with PSR-17 factories in one package (7.0 or later)
- symfony/http-client: Its Psr18Client, together with nyholm/psr7 for the PSR-17 factories
Requiring the virtual package psr/http-client-implementation, as the discovery documentation
suggests for a library, looks like the stricter fix. In a local run it did not fail the install,
because php-http/discovery lists that package under provide in its own composer.json. With
the plugin allowed, the same install added symfony/http-client 7.4.21 and nyholm/psr7 1.8.2 on
its own. With no allow-plugins entry, a non-interactive install skipped the plugin silently:
discovery marks its plugin plugin-optional, and Composer’s
plugin manager
declines an optional plugin it cannot ask about.
A redirect becomes an error. Guzzle’s sendRequest sets allow_redirects to false, and the test
pins it. Symfony HttpClient follows
up to 20 redirects by default. Its
Psr18Client sets no limit
of its own, and it followed a 302 in a local run. Treat any status
outside 2xx as a result the SDK decides on.
When not to do this
Do not use discovery in an application. The application knows which client it installed, so it can construct that client with its own configuration and pass it in.
Do not let the discovered default reach production unconfigured. A Guzzle client built by discovery
carries Guzzle’s default timeout
of 0, which puts no limit on the request as a whole. Guzzle 8.2.0’s
request options add a
60-second default for connecting, which does not bound the response. Document that callers should
pass a client with a timeout set.
Do not switch an SDK whose users are mostly beginners running scripts. Each of them pays the extra install without running a framework that gains from it. Stripe’s choice of plain cURL is a fair answer for that audience.
Do not rely on Guzzle middleware inside the SDK. Once the client arrives through ClientInterface,
anything beyond sendRequest belongs to the caller, so retries and logging become decorators around
the interface or the caller’s job.
Related how-tos
Last verified
Verified 2026-10-15 against PHP 8.3.6, php-http/discovery 1.20.0, psr/http-client 1.0.3,
psr/http-factory 1.1.0 and Guzzle 8.2.0, all committed under each directory’s vendor/. The four
output blocks are what the preceding command printed. The Composer plugin and Symfony observations
were made in a scratch directory with network access, under Composer 2.8.12 and Symfony HttpClient
7.4.21, and are described rather than captured.
Footnotes
-
The PSR-18 meta document records the argument. The initial idea was to let a client be configured to throw on 4xx and 5xx. It was dropped because a consuming library would then check every response twice, once by status and once by catching. The same document calls a client configured to follow redirects or throw a decision to “break the specification” that the application then owns. Guzzle’s
sendRequesttakes the hint and turns off bothhttp_errorsandallow_redirectsbefore it sends. ↩︎ Back to text -
PSR-18 began as a different interface. The meta document explains that the php-http team created HTTPlug in 2015 and tagged a stable release in January 2016. After over 3 million downloads in the two years that followed, the informal standard was converted into a formal one. The adapter discovery suggests belongs to that first interface, which is how a message about PSR-18 came to recommend an HTTPlug package. ↩︎ Back to text