How-to › Ship an SDK

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.

Audience
API producer
Level
intermediate
Topic
Design SDK ergonomics per language
Languages
PHP
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
OpenAPI Generator php with GuzzleYou generate from a spec and your callers already run GuzzleEvery caller installs Guzzle, so a different Guzzle major in their application is a Composer conflictCallers run Symfony or another major; the generator’s psr-18 library option is marked beta
PSR-18 with php-http/discoveryA hand-written SDK used inside frameworks that already own an HTTP clientA longer first install, because a beginner has to add an implementation before any call worksYour users are standalone scripts, where a bundled client saves a support ticket
Stripe PHP curl clientAn SDK that must run on any host with ext-curl and no other packageIts own client interface, swapped through a static ApiRequestor::setHttpClient, so PSR-18 middleware never sees a callCallers expect their client’s logging, retries, and proxy settings to apply
Symfony HttpClient Psr18ClientYou want PSR-18 types and a default that works with no extra installEvery caller installs symfony/http-client and its contracts, even callers who pass their own clientYour 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.

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

  1. 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 sendRequest takes the hint and turns off both http_errors and allow_redirects before it sends. ↩︎ Back to text

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

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.