# How to build a PHP SDK that takes any PSR-18 HTTP client

> Type a PHP SDK against the PSR-18 and PSR-17 interfaces, let callers pass the HTTP client they already run, and fail with an install command when none is present.

Source: https://voxgig.com/howto/php-sdk-on-psr-18-http-clients

- Audience: api-producer
- Level: intermediate
- Languages: php
- Verified: 2026-10-15
- Published: 2026-10-15

## 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](https://www.php-fig.org/psr/psr-18/) for the client
and [PSR-17](https://www.php-fig.org/psr/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](https://openapi-generator.tech/docs/generators/php/) | 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](https://docs.php-http.org/en/latest/discovery.html) | 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](https://packagist.org/packages/stripe/stripe-php) | 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](https://symfony.com/doc/current/http_client.html#psr-18-and-psr-17) | 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`](https://github.com/stripe/stripe-php/blob/v22.0.0/lib/HttpClient/ClientInterface.php)
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`.

```json title="sdk/composer.json"
    "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](https://docs.php-http.org/en/latest/discovery.html#using-discovery-in-a-shared-library)
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.

```php title="sdk/src/MeterClient.php"
    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.

```php title="sdk/src/MeterClient.php"
        // 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.

```bash
php sdk/demo.php
```

```text output
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.

```bash
php sdk/tests/test.php
```

```text output
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.

```bash
php bare/demo.php
```

```text output
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](https://packagist.org/packages/php-http/guzzle7-adapter) 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:

```bash
composer suggest --working-dir=bare
```

```text output
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](https://github.com/composer/composer/blob/2.8.12/src/Composer/Plugin/PluginManager.php)
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](https://symfony.com/doc/current/http_client.html#redirects) follows
up to 20 redirects by default. Its
[`Psr18Client`](https://github.com/symfony/http-client/blob/v7.4.21/Psr18Client.php) 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`](https://docs.guzzlephp.org/en/stable/request-options.html#timeout)
of `0`, which puts no limit on the request as a whole. Guzzle 8.2.0's
[request options](https://github.com/guzzle/guzzle/blob/8.2.0/src/RequestOptions.php) 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

- [Give a Ruby SDK keyword arguments and Faraday middleware](/howto/ruby-sdk-keyword-arguments-and-faraday-middleware)

- [Wrap an SDK transport in a middleware chain](/howto/wrap-an-sdk-transport-with-middleware)

- [Decide between generating and hand-writing a client](/howto/generate-or-hand-write-a-small-api-client)

- [Add an API key with Guzzle middleware in PHP](/howto/api-key-guzzle-middleware)

- [Check whether a vendor SDK lets you control the transport](/howto/check-transport-control-in-a-vendor-sdk)

## 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.

[^1]: The [PSR-18 meta document](https://www.php-fig.org/psr/psr-18/meta/) 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 `sendRequest` takes the hint and turns off
both `http_errors` and `allow_redirects` before it sends.

[^2]: PSR-18 began as a different interface. The [meta
document](https://www.php-fig.org/psr/psr-18/meta/#background) explains that the php-http team
created [HTTPlug](https://docs.php-http.org/en/latest/httplug/introduction.html) 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.