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 Factory Laravel Package

graham-campbell/guzzle-factory

A simple Guzzle factory with sensible defaults for PHP 7.4–8.5. Create ready-to-use clients in one call, customize the handler stack, and optionally enable best-effort transport sharing for handler-lifetime reuse.

View on GitHub
Deep Wiki
Context7

Getting Started

What you are installing. graham-campbell/guzzle-factory is a single class with a static make() method that returns a stock GuzzleHttp\Client with opinionated defaults pre-applied — retry middleware, TLS 1.2 minimum, sane timeouts, optional base URI. There is no service provider, no facade, no config file shipped. It is a construction helper, not an HTTP abstraction layer, so nothing in your codebase changes type when you adopt it.

Prerequisites before you type composer require. Check two constraints first, because they are the usual source of a stalled rollout:

composer require "graham-campbell/guzzle-factory:^8.0"
composer why-not guzzlehttp/guzzle 7.11   # v8 requires Guzzle >= 7.11
php -v                                    # package supports 7.4-8.5; Laravel 9-11 needs 8.0+

If any other package pins Guzzle below 7.11 you will not get v8 installed, and you do not want to land on v7 — the API differs (see Gotchas).

Where to look first. Read the README usage block, then the V8.0 changelog entry specifically. v8 removed direct handler-stack creation from the public API and replaced it with a HandlerStack mutator callback plus a transport-sharing argument. Anything you find in blog posts or older internal wrappers that calls a handler-stack accessor is pre-v8 and will not compile.

First use case — one upstream API. Take the integration with the least business risk (analytics, notifications, a read-only reference API) and replace its hand-rolled client:

use GrahamCampbell\GuzzleFactory\GuzzleFactory;

$client = GuzzleFactory::make(['base_uri' => 'https://analytics.example.com']);
$response = $client->get('/v1/events', ['query' => ['since' => $since]]);

Minimal Laravel wiring. Do not scatter that call through controllers. Add one config file and one seam class on day one:

// config/http-clients.php
return [
    'analytics' => [
        'base_uri' => env('ANALYTICS_URL', 'https://analytics.example.com'),
        'timeout'  => 10,
    ],
];
// app/Support/Http/UpstreamClientFactory.php
namespace App\Support\Http;

use GrahamCampbell\GuzzleFactory\GuzzleFactory;
use GuzzleHttp\Client;
use GuzzleHttp\HandlerStack;

final class UpstreamClientFactory
{
    /** @var array<string, Client> */
    private array $clients = [];

    public function make(string $profile, ?callable $customize = null): Client
    {
        return $this->clients[$profile] ??= GuzzleFactory::make(
            config("http-clients.{$profile}"),
            null,
            static function (HandlerStack $stack) use ($customize): void {
                $customize?->__invoke($stack);
            },
        );
    }
}

Bind it once in AppServiceProvider::register() as a singleton, and have application code depend on UpstreamClientFactory or on an injected Client — never on the vendor static method. This one small class is your exit ramp and your single enforcement point.

Implementation Patterns

Per-service profiles instead of per-call options. Keep base URI, timeout, connect timeout and retry budget in config/http-clients.php (or grouped under config/services.php if you prefer one file). Application code asks for a profile by name; it never passes raw Guzzle options. That gives you one place to change a timeout during an incident and one place for a reviewer to see the blast radius.

Contextual binding for typed clients. Rather than injecting a bare Client everywhere, wrap each upstream in a small typed client and bind it contextually:

$this->app->when(BillingGateway::class)
    ->needs(Client::class)
    ->give(fn () => app(UpstreamClientFactory::class)->make('billing'));

The constructor stays public function __construct(private Client $http), which means existing tests that pass a MockHandler-backed client keep working unchanged.

The handler stack callback is the real extension point. This is where you put everything the package deliberately does not do: logging, correlation IDs, tracing, metrics, and any retry behaviour you need to override.

use GuzzleHttp\Middleware;
use Psr\Http\Message\RequestInterface;

$factory->make('analytics', static function (HandlerStack $stack): void {
    $stack->push(Middleware::mapRequest(
        static fn (RequestInterface $r) => $r->withHeader('X-Request-Id', (string) Str::uuid())
    ), 'correlation-id');

    $stack->push(Middleware::log(Log::channel('http')->getLogger(), new MessageFormatter()), 'logger');
});

Push order matters: middleware closer to the top of the stack sees the request first. Put correlation IDs above logging so the log line carries the ID.

Transport sharing is a worker optimisation, not a default. Leave the second argument null unless you are running Octane or long-lived queue workers and have measured connection setup as a cost. When you want it:

use GuzzleHttp\TransportSharing;

$client = GuzzleFactory::make(['base_uri' => $uri], TransportSharing::HANDLER_PREFER);

Note the naming: HANDLER_PREFER is best-effort, handler-lifetime reuse. It is not a connection pool with a lifecycle you control, and it is only useful where the process is reused.

Cache clients per profile inside the seam. The ??= in the seam above is deliberate for queue workers and Octane: constructing a client per job is cheap, but leaking a curl handle per job is not. In standard FPM the cache is per-request and harmless.

Testing: mock at the seam, not at the vendor. Swap an instance of the typed client or the Client binding:

$mock = new MockHandler([new Response(200, [], '{"ok":true}')]);
$this->app->instance(Client::class, new Client(['handler' => HandlerStack::create($mock)]));

If you genuinely must exercise the package's own stack (for example to prove retries fire), inject your handler via the customizer with $stack->setHandler($mock) — that replaces the base transport while keeping the retry and other middleware in place. Guard this with a test that asserts behaviour (a 503 is retried, a 429 is retried, a 404 is not), not internal config arrays. The package's defaults are not a stable public contract across majors.

Coexist with Laravel's Http facade rather than fighting it. The facade is Guzzle underneath but builds its own client, so your factory defaults do not apply there. Either route facade calls through the seam, or mirror the same values once in a provider:

Http::globalOptions(['timeout' => 10, 'connect_timeout' => 3]);

Pick one direction and document it; two divergent HTTP default sets is the failure mode this package is meant to fix.

Migrate by strangler, not big-bang. Audit first: grep -rn "new GuzzleHttp\\Client" app/ and flag every verify => false, every hardcoded timeout, every hardcoded URI. Then pilot two or three non-critical integrations, measure the config lines deleted, and only then move internal services and revenue-path integrations. Finish with a CI ratchet that bans new GuzzleHttp\Client( and 'verify' => false outside app/Support/Http.

Gotchas and Tips

The v8 second argument changed meaning. In earlier majors the second parameter let you supply a base handler; in v8 it is the transport-sharing mode. Code that passed a callable or handler there will either TypeError or, worse, be silently misused. Treat the v7 → v8 move as a rewrite of every call site, not a version bump. This is the single most likely thing to bite you.

Automatic retries are a policy decision, not a convenience. The factory retries by default, and Guzzle's retry middleware does not know or care about HTTP semantics. A retried POST to a payment or webhook endpoint can double-charge. Before enabling a profile for a non-idempotent integration, either disable retries for that profile via the handler stack or send an idempotency key on every request. Ask explicitly: does this endpoint tolerate replays?

Retries multiply tail latency. Three retries against a 30s timeout is a 120s worst case. In a queue worker that is a stuck job; on a web request that is a PHP timeout. Set timeout and connect_timeout per profile with the retry budget in mind, and put long-running integrations on the queue rather than in the request cycle.

The TLS 1.2 floor is a security win and a compatibility risk. Since v7 the stack sets a 1.2 minimum. Legacy partner endpoints occasionally still negotiate TLS 1.0/1.1; you will see a connection-level ConnectException, not an HTTP error, which is easy to misdiagnose as a DNS or network fault. Check the partner's TLS posture during the audit, not during the incident.

Constants and handler-stack accessors are private since v6. You cannot read the retry codes or retry count from outside the class, and v8 removed the public handler-stack creation API. If you need different retry codes or a different backoff, push your own Middleware::retry (and pop or bypass the package's) via the customizer — do not depend on the defaults being configurable through the options array. Write a behavioural test so a minor upgrade that changes defaults fails loudly.

Header arrays are probably replaced wholesale, not merged. Passing 'headers' => ['X-Foo' => 'bar'] into make() most likely discards any default header the package sets (including its User-Agent). Test it once in your own suite and, if confirmed, always pass a complete header set including your User-Agent.

Exceptions are truncated. Response bodies in error messages have been truncated (250 characters since v4.1). For debugging a failing upstream, add Middleware::history() in the customizer and dump $container on failure — do not rely on the exception message for payload detail.

Do not Mock the static. GuzzleFactory::make() is static, so Mockery::mock(GuzzleFactory::class) is a dead end. Your seam class in the container is the mockable surface.

Pin the constraint and note the supply chain. "^8.0" in composer.json, a single maintainer, Tidelift commercial support, a consistent release cadence. Low probability of abandonment, medium impact if the seam is absent — which is exactly why the seam is non-negotiable.

Debugging checklist when a call behaves unexpectedly. In order: (1) confirm the profile's config is actually reaching make() — config() merge order in config:cache is a common culprit; (2) push Middleware::history() and dump the transaction; (3) confirm whether a failure is a transport exception (TLS, DNS, connect timeout) versus an HTTP status; (4) verify retry count empirically with a MockHandler rather than assuming; (5) check whether the Http facade is being used somewhere in the same code path with different defaults.

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.
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core