Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Guzzle Services Laravel Package

guzzlehttp/guzzle-services

Build SDK-style API clients with service descriptions: map named operations and parameters to HTTP requests and transform responses into result data, instead of hand-writing request serializers for every endpoint.

View on GitHub
Deep Wiki
Context7

Getting Started

Pick the version line before you write a single operation. guzzlehttp/guzzle-services 2.x requires Guzzle 8 and PHP >=7.4,<8.7; 1.7.x is the line for Guzzle 7. Most Laravel apps in the wild are on Guzzle 7, so:

composer require guzzlehttp/guzzle-services:^1.7

Do not pin below 1.5.4 — that release fixed XML CDATA injection (GHSA-q8r6-5hfw-5jff). Within 1.7.x, prefer a recent patch: the 1.7 series contains real bug fixes (XML parsing for namespaced sentAs properties, PCRE error reporting, header serialization).

Mental model in three objects. A Description is a plain PHP array describing baseUri, operations, and models. A GuzzleClient (GuzzleHttp\Command\Guzzle\GuzzleClient) wraps a normal Guzzle client plus that description and exposes each operation as a magic method. Every call returns a GuzzleHttp\Command\Result, which is ArrayAccess/Countable/IteratorAggregate — not an array.

Where to look first: docs/service-descriptions.md is the real reference (operation keys, parameter location/sentAs/filters, response models). Read it before the cookbook. Skip GuzzleClient's source until you hit something the docs don't answer.

First use case — one endpoint, wrapped for Laravel. Don't start with a forty-operation SDK. Describe a single read endpoint, bind it as a singleton, and confirm the request on the wire:

// app/Services/Acme/AcmeDescription.php
final class AcmeDescription
{
    public static function make(): Description
    {
        return new Description([
            'baseUri' => config('services.acme.base_uri'),
            'operations' => [
                'getCustomer' => [
                    'httpMethod' => 'GET',
                    'uri' => '/customers{/id}',
                    'responseModel' => 'customer',
                    'parameters' => [
                        'id' => ['type' => 'string', 'location' => 'uri', 'required' => true],
                    ],
                ],
            ],
            'models' => [
                'customer' => [
                    'type' => 'object',
                    'additionalProperties' => ['location' => 'json'],
                ],
            ],
        ]);
    }
}
// app/Providers/AcmeServiceProvider.php
$this->app->singleton(GuzzleClient::class, fn () => new GuzzleClient(
    new \GuzzleHttp\Client(['timeout' => 10]),
    AcmeDescription::make(),
    ['defaults' => ['headers' => ['Accept' => 'application/json']]]
));

Before you ever send traffic, inspect what the description produces:

$command  = $client->getCommand('getCustomer', ['id' => '123']);
$request  = $client->serializer()->serialize($command); // check URI, headers, body

That single loop — getCommand() → serialize → inspect PSR-7 request — resolves the large majority of "why is my request wrong" questions without a network call.

Implementation Patterns

Keep descriptions in classes, not inline in controllers. A final class AcmeDescription { public static function make(): Description } per resource (CustomersDescription, InvoicesDescription) merged with array_replace_recursive keeps files readable and lets the container memoize the result. Because descriptions are plain arrays, they also survive config:cache if you put them in config/services/acme.php — but then never put secrets in them (see below).

Wrap the magic method, always. $client->getCustomer([...]) is convenient but invisible to PHPStan, IDEs, and grep. Put every operation behind a typed method on a thin service class and let domain code see DTOs:

final class Acme
{
    public function __construct(private GuzzleClient $client) {}

    public function customer(string $id): Customer
    {
        return Customer::fromArray($this->client->getCustomer(['id' => $id])->toArray());
    }
}

This is also where you handle Result → model mapping once instead of in twelve places.

Start loose, tighten later. 'additionalProperties' => ['location' => 'json'] gives you the whole decoded body as a nested Result with zero schema work — perfect for an unstable or undocumented API. Once the payload settles, replace it with typed properties (type, required, enum, pattern, minLength) so bad API changes surface as DeserializationException in staging rather than Undefined index in production. sentAs handles wire names that aren't valid PHP identifiers; array types need 'items' => [...].

Auth goes in middleware, not the description. The description is static configuration; tokens are not. Compose a HandlerStack and use Middleware::mapRequest for bearer tokens (pull from config/Cache, refresh on expiry), Middleware::retry() for 429/503, and Guzzle's Middleware::log() if you want PSR-3 output. Description-level 'static' parameters are fine for things like 'api-version' => '2024-01-01', never for credentials.

Parallelism is the main reason to tolerate the description overhead. Batch jobs that fan out to an API:

$promises = [];
foreach ($ids as $id) {
    $promises[$id] = $client->executeAsync($client->getCommand('getCustomer', ['id' => $id]));
}
$settled = \GuzzleHttp\Promise\Utils::settle($promises)->wait();

Use settle() rather than all() inside queued jobs so one 404 doesn't discard fifty successes; inspect $settled[$id]['state'].

Test against MockHandler, not the network. new Client(['handler' => HandlerStack::create(new MockHandler([new Response(200, [], $json)]))]) injected into new GuzzleClient(...) lets you assert wire format with Middleware::history, and assert client-side validation (ValidationException) with no mock at all. Add a contract test that runs the same description against a recorded fixture set — that's your regression net when the vendor changes their API.

Gotchas and Tips

  • Result is not an array. json_encode($result) and $result['a']['b'] ?? null behave differently than you expect on nested data; call ->toArray() at the boundary. Also remember explicit JSON nulls are preserved as properties (1.5.3+), so isset() and array_key_exists() diverge.
  • httpMethod defaults to GET. In 2.0 an operation with no httpMethod silently becomes GET, which turns a typo'd POST into a request that half-works. Always state it explicitly and uppercase it — 1.6 deprecated lowercase methods precisely because 2.0 preserves casing.
  • Write 2.0-ready descriptions now. baseUrl → baseUri, responseClass → responseModel, header values must be strings or non-empty string arrays, non-finite floats are rejected, string enums match strictly. Everything 1.6/1.7 deprecated is removed in 2.0; clearing deprecation notices today makes the eventual bump a Composer change.
  • 204s and empty bodies need a response model too. A 'type' => 'object' model with no additionalProperties is the cheap answer. And 2.0 rejects scalar top-level JSON bodies ("ok", 123) outright — if an endpoint does that, use 'process' => false on the operation (or the client) and read the raw PSR-7 response from the response result key.
  • Exception funnel. Catch GuzzleHttp\Command\Exception\CommandException first: it carries getCommand(), getRequest(), getResponse(), and wraps the Guzzle exception in getPrevious() — that's where your ConnectException/RequestException status codes live. Async fan-outs throw CommandTransferException; use getFailedCommands()/getExceptions() rather than unwrapping by hand. Map these to your app's exceptions in the wrapper class so controllers never see Guzzle types.
  • Http::fake() does not intercept this. Laravel's HTTP client fakes only apply to Illuminate\Http\Client. Use MockHandler; if you want a fake at the container level, bind a fake GuzzleClient built on the mock stack in a test service provider.
  • Query array serialization is the most common wire mismatch. Array-typed query parameters don't become foo=a&foo=b by default. Either add a filters callable that joins/expands them into the exact string the API wants, or send the value pre-serialized. Filters run before validation and must return the value — verify with the serialize-and-inspect loop.
  • additionalParameters is an escape hatch with a cost. Declaring 'additionalParameters' => ['location' => 'query'] stops unknown-parameter exceptions, which is great for endpoints with a long undocumented query tail and terrible for catching typos. Scope it per operation, never globally.
  • Debugging tips that pay for themselves: 'defaults' => ['debug' => true] on a local scratch client prints the wire dump; Middleware::history in a test container gives you the exact request the description built. Remember that PHP 8.2 #[\SensitiveParameter] redaction (added in 2.0) only sanitizes backtraces — your request logs and Guzzle debug output will still contain the API key.
  • There is no OpenAPI importer. Every operation is hand-written or generated by your own script. That's the real cost of this package; budget for it. If the API surface is three endpoints, use plain Guzzle (or Laravel's Http facade) and skip the description layer entirely — this package earns its keep when the surface is large, stable, and you want validation and response mapping declarative rather than spread across fifty methods.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
teamzac/geocoder
ideatica/config-writer
keaton-php/pipe-dream
jacksonwebservices/laravel-user-suite
maksimru/conditional-laravel-debugbar
gismatul/nova-support-chat
gismatul/laravel-support-chat
networkrailbusinesssystems/oracle-api
alphabalex/messaging-made-easy
rawphp/warp
shootkiran/merobug
narakode/fineauth
numencode/wn-syncops-plugin
ibnnajjaar/mib-global-pay
ably/pubsub-server
mstfkhazaal/defender
mfonte/base62x
karnoweb/metapilot
tobya/bcsapiwrapper
swalker2/cpanel