jord-jd/psr-18-guzzle-adapter
A simple PSR-18 adapter for Guzzle, supporting PHP 7.1+ with Guzzle 6 or 7. It keeps Guzzle 6 projects compatible with PSR-18 clients and normalizes Guzzle exceptions to the PSR-18 exception interfaces.
Install and verify what you actually got. The package is a single-purpose shim, so spend two minutes confirming its surface before designing around it:
composer require jord-jd/psr-18-guzzle-adapter
ls vendor/jord-jd/psr-18-guzzle-adapter/src
cat vendor/jord-jd/psr-18-guzzle-adapter/composer.json
Check three things in that output: the exact adapter FQCN (third-party write-ups frequently guess it — don't trust anything but src/), the Guzzle version constraint (^7 is the realistic floor given the release history), and whether a Laravel service provider is actually shipped. Several of the "install" snippets circulating online register a Psr18GuzzleAdapterServiceProvider that may not exist; if src/ contains only the adapter class, skip provider registration entirely and bind it yourself.
Understand the contract you're adopting. PSR-18 is deliberately tiny — one method:
interface ClientInterface {
public function sendRequest(RequestInterface $request): ResponseInterface;
}
That's the whole abstraction. Everything you lose (async, base_uri, per-request options arrays, middleware) and everything you keep (the full Guzzle handler stack, configured on the wrapped client) follows from that single line. Read src/ once, then read the PSR-18 spec.
First use case: bind it once, inject the interface. In AppServiceProvider::register():
use GuzzleHttp\Client;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use GuzzleHttp\Psr7\HttpFactory;
use JordJD\Psr18GuzzleAdapter\GuzzleAdapter; // confirm FQCN in src/
$this->app->singleton(ClientInterface::class, fn () => new GuzzleAdapter(
new Client(['timeout' => 10, 'connect_timeout' => 5])
));
$this->app->singleton(RequestFactoryInterface::class, fn () => new HttpFactory());
Then a first call site — note you must supply a PSR-17 factory, the adapter doesn't give you one:
final class ExchangeRates
{
public function __construct(
private ClientInterface $http,
private RequestFactoryInterface $requests,
) {}
public function latest(string $base): array
{
$request = $this->requests->createRequest('GET', "https://api.example.com/latest?base={$base}");
$response = $this->http->sendRequest($request);
return json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR);
}
}
Because GuzzleHttp\Psr7\HttpFactory implements all three PSR-17 factory interfaces (RequestFactoryInterface, StreamFactoryInterface, UriFactoryInterface), one binding covers you. If you'd rather not add the dependency, guzzlehttp/psr7 is already pulled in by Guzzle.
Where not to use it. Laravel's own Http facade does not resolve ClientInterface from the container — binding this adapter will not reroute Http::get(). If your codebase is 100% Http::, this package buys you nothing today. It pays off when you have consumers written against ClientInterface (SDK clients, php-http-style libraries, PSR-15 middleware stacks).
Adapter as a decorator seam, not a class you reference. The highest-value pattern: let exactly one binding know about GuzzleAdapter, and let everything else depend on ClientInterface. That makes the adapter swappable and, more importantly, makes your code testable without Guzzle:
// Decorator: PSR-18 in, PSR-18 out. No Guzzle types leak.
final class LoggingClient implements ClientInterface
{
public function __construct(
private ClientInterface $inner,
private LoggerInterface $log,
) {}
public function sendRequest(RequestInterface $request): ResponseInterface
{
$start = microtime(true);
try {
$response = $this->inner->sendRequest($request);
$this->log->info('http', [
'uri' => (string) $request->getUri(),
'status' => $response->getStatusCode(),
'ms' => (microtime(true) - $start) * 1000,
]);
return $response;
} catch (ClientExceptionInterface $e) {
$this->log->error('http failed', ['uri' => (string) $request->getUri(), 'e' => $e]);
throw $e;
}
}
}
This is the real answer to "how do I get middleware with PSR-18?" — you wrap at the PSR-18 level for cross-cutting concerns (logging, metrics, tracing, circuit breaking), and you configure the Guzzle handler stack on the wrapped client for transport concerns (retries, redirects, proxies).
Split the two configuration layers deliberately. Guzzle config lives on the inner Client; application concerns live on the decorator.
$stack = HandlerStack::create();
$stack->push(Middleware::retry($decider, $delay));
$stack->push(Middleware::log($logger, $formatter));
$guzzle = new Client([
'handler' => $stack,
'timeout' => 10,
'connect_timeout' => 3,
'http_errors' => true,
'headers' => ['User-Agent' => 'myapp/1.0'],
]);
$this->app->singleton(ClientInterface::class, fn () =>
new LoggingClient(new GuzzleAdapter($guzzle), app(LoggerInterface::class))
);
Testing: bind a fake, never touch the network. Because consumers type-hint ClientInterface, a 20-line stub replaces the adapter in tests:
final class FakeClient implements ClientInterface
{
public array $sent = [];
public function __construct(private ResponseInterface $response) {}
public function sendRequest(RequestInterface $request): ResponseInterface
{
$this->sent[] = $request;
return $this->response;
}
}
$fake = new FakeClient(new Response(200, [], json_encode(['rate' => 1.23])));
$this->app->instance(ClientInterface::class, $fake);
$this->get('/rates/eur')->assertOk();
$this->assertSame('base=eur', $fake->sent[0]->getUri()->getQuery());
For tests that must exercise real Guzzle behaviour (retries, redirect following), keep the adapter and install guzzlehttp's MockHandler on the wrapped client — Middleware::history() gives you the assertions.
Inject per-consumer, not globally, when APIs differ. Rather than one global ClientInterface with a shared base_uri, register keyed bindings and hand each service a client configured for its upstream:
$this->app->when(BillingGateway::class)
->needs(ClientInterface::class)
->give(fn () => new GuzzleAdapter(new Client([
'base_uri' => 'https://billing.internal/',
'timeout' => 30,
])));
Caveat: base_uri is honoured by Guzzle's request()/get() helpers, not by sendRequest(), which requires an absolute URI. Use it only as documentation-by-config, or build absolute URIs in the consumer.
Queue and job usage is where PSR-18 shines. Jobs are serialised, so constructor-injected ClientInterface gets resolved fresh on execution — no serialising a Guzzle client. Keep API clients as thin, stateless services and let the worker resolve them:
final class SyncOrder implements ShouldQueue
{
public function __construct(public int $orderId) {}
public function handle(OrderApi $api): void { $api->push($this->orderId); }
}
Migration sequencing that actually works. Swap one leaf consumer at a time to ClientInterface while the adapter satisfies it; leave Guzzle-typed code alone until its consumer is PSR-18. Don't refactor the container and all call sites in one commit — with 0 dependents and 9 stars upstream, you want your diff to be the thing you debug, not someone else's package.
Exception translation is the #1 thing to verify. PSR-18 mandates that failures throw Psr\Http\Client\ClientExceptionInterface; Guzzle's GuzzleException hierarchy does not implement it. A thin adapter must catch and rethrow. Prove it before you rely on it:
try {
$client->sendRequest($requests->createRequest('GET', 'https://127.0.0.1:1/'));
} catch (\Psr\Http\Client\ClientExceptionInterface $e) {
// expected — if this never fires, the adapter is leaking Guzzle exceptions
} catch (\GuzzleHttp\Exception\GuzzleException $e) {
// adapter is leaking; wrap it yourself in a decorator
}
If it leaks, add a decorator that catches GuzzleException and rethrows it wrapped in a class implementing NetworkExceptionInterface (connection/DNS/timeout) or RequestExceptionInterface (malformed request, non-idempotent retry hazard). Getting this wrong means every catch (ClientExceptionInterface) in your codebase silently misses.
No per-request options — this is the biggest day-to-day loss. You cannot pass ['timeout' => 60, 'headers' => [...]] to sendRequest(). Per-request timeouts, headers, auth, and verify must go into the request object itself (PSR-7 headers, or a separate client binding), not a call argument. If your code needs per-call tuning, either build it into the request or skip the abstraction for that consumer.
Defaults will bite you. Guzzle's timeout defaults to 0 — infinite. Wrapped behind an interface nobody reads, that's a hung PHP-FPM worker discovered in production. Set timeout and connect_timeout explicitly on the wrapped client, always.
Retries and redirects don't live in PSR-18. There is no retry contract. Retries are a Guzzle middleware on the inner client, so they are invisible from the ClientInterface side. Two consequences: (a) document the retry policy next to the binding, because nothing in the type signature reveals it; (b) your LoggingClient decorator will see one logical request while the handler stack may perform three. Log the attempt count from Middleware::history() or a Guzzle on_stats callback if that matters.
http_errors interacts with exception handling. With http_errors => true (the default), Guzzle throws on 4xx/5xx — a RequestException that a correct adapter converts to ClientExceptionInterface, which means your consumer cannot distinguish a 404 from a DNS failure unless the adapter is careful to preserve the response. Check src/, and if needed set http_errors => false and let consumers inspect getStatusCode() — often the better pattern behind a client interface anyway.
No async. PSR-18 is synchronous only. If you need concurrency, GuzzleHttp\Client::requestAsync() on the inner client is the escape hatch — but that means injecting the Guzzle client into that consumer, reintroducing the coupling you adopted this package to remove. Decide per-service: use PSR-18 for correctness and testability, and keep a Guzzle-typed path for genuinely high-fanout call sites.
Don't double-bind. Binding ClientInterface in one service provider and ClientInterface (or a Guzzle-typed alias) to something else elsewhere — e.g. a package's own auto-discovery provider — makes behaviour order-dependent. php artisan about won't show you this; use $this->app->getBindings() in a tinker session to confirm exactly one producer for the interface.
Package risk is real and should shape your pinning. 9 stars, 0 dependents, one maintainer, LGPL-3.0, and a v3.0.0 with an empty changelog. This is roughly 30 lines of code. Two practical implications: (1) pin the exact version in composer.json and treat upgrades as a manual review, not composer update; (2) if your organisation is licence-sensitive or dislikes single-maintainer transitives, copy the adapter into app/Support/ — you avoid the dependency entirely and the LGPL question with it. The abstraction's value comes from the interface, not from this particular implementation.
Debugging is easier below the adapter. When a request misbehaves, put debug => true or a Middleware::tap() on the inner Guzzle client temporarily — you'll see raw wire traffic, which the ResponseInterface alone won't give you. Stack traces will show Guzzle internals regardless; that's expected, not a bug.
Static analysis needs a nudge. ClientInterface::sendRequest() returns ResponseInterface, so getContents() on getBody() is fine, but (string) casts and Laravel-specific response helpers aren't available. If consumers were previously typed against GuzzleHttp\ClientInterface, PHPStan will surface every leak quickly — run it on the branch that swaps the binding; it's the cheapest migration checklist you'll get.
How can I help you explore Laravel packages today?