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

Zttp Laravel Package

kitetail/zttp

Zttp is a simple, elegant HTTP client for PHP built on Guzzle, offering a fluent, Laravel-style API for GET, POST, JSON, form, and header requests with minimal boilerplate. Chain methods like withHeaders, asJson, and withoutRedirecting, and install easily via Composer.

View on GitHub
Deep Wiki
Context7

Getting Started

Install (no service provider, no config file, no facade registration required):

composer require kitetail/zttp

The package is two small classes: Kitetail\Zttp\Zttp and Kitetail\Zttp\ZttpResponse. Both live under vendor/kitetail/zttp/src/ and together are only a couple hundred lines — read them end-to-end before your first PR. The README covers 90% of the API surface; the remaining 10% is withOptions(), which is your escape hatch to raw Guzzle.

First use case — replacing a hand-rolled cURL/Guzzle call in a controller or job:

use Kitetail\Zttp\Zttp;

$response = Zttp::withToken(config('services.acme.token'))
    ->accept('application/json')
    ->timeout(5)
    ->get('https://api.acme.test/v1/customers', ['page' => 2]);

if ($response->isSuccess()) {
    $customers = $response->json();       // decoded array
} else {
    report(new \RuntimeException($response->status() . ': ' . $response->body()));
}

Everything is fluent and order-sensitive: chain the modifiers first (withHeaders, withToken, withBasicAuth, asJson, asFormParams, timeout, retry, withoutRedirecting, withoutVerifying), then call the verb (get, post, put, patch, delete). The verb call terminates the chain and returns a ZttpResponse.

Implementation Patterns

Verbs, query strings, and bodies. get($url, $query) takes query params as the second argument; post/put/patch($url, $data) take a body. delete($url, $data) accepts a body too (unusual, but some APIs need it).

Zttp::asFormParams()->post('https://legacy.test/oauth/token', [
    'grant_type' => 'client_credentials',
]);

Zttp::asJson()->put("https://api.test/v1/users/{$id}", ['name' => 'Ada']);

By default the body format is JSON (Content-Type: application/json); call asFormParams() when hitting an endpoint that wants application/x-www-form-urlencoded.

Auth helpers. withToken($token) sets Authorization: Bearer …; withBasicAuth($user, $pass) sets Basic. For anything else (HMAC signatures, X-Api-Key), use withHeaders() — and remember headers can be computed per request, which is what makes signing possible.

Zttp::withHeaders([
    'X-Api-Key'    => config('services.acme.key'),
    'X-Signature'  => hash_hmac('sha256', $body, $secret),
])->withHeaders(['Accept' => 'application/json'])->post($url, $body);

Wrap it, don't sprinkle it. The single biggest day-to-day improvement is putting each third-party API behind one class that owns base URL, auth, timeouts, and error translation. That gives you one place to swap in Http:: later (see Gotchas).

class AcmeClient
{
    public function __construct(private string $token, private string $base = 'https://api.acme.test/v1') {}

    public function customer(int $id): array
    {
        $res = Zttp::withToken($this->token)
            ->accept('application/json')
            ->timeout(5)
            ->retry(3, 200)
            ->get("{$this->base}/customers/{$id}");

        if ($res->status() === 404) {
            throw new CustomerNotFound($id);
        }

        if (! $res->isSuccess()) {
            throw new AcmeApiException($res->status(), $res->body());
        }

        return $res->json();
    }
}

Share a configured client for hot paths. Zttp::__callStatic() builds a fresh GuzzleHttp\Client on every static call, which means no connection reuse. When you're looping over hundreds of requests, instantiate once and pass it around — or bind a preconfigured instance in the container:

// AppServiceProvider::register()
$this->app->singleton(AcmeClient::class, fn () => new AcmeClient(config('services.acme.token')));

You can also inject Guzzle configuration (base URI, handler stack, middleware) at construction:

$zttp = new Zttp(new \GuzzleHttp\Client([
    'base_uri' => 'https://api.acme.test/v1/',
    'handler'  => $stack, // e.g. HandlerStack with logging/tracing middleware
]));

Observability with Guzzle middleware. Zttp doesn't log anything. Add Guzzle's Middleware::log() / Middleware::history() / a tap middleware via the handler stack at construction time — that's the cleanest place, and it captures every request made through that instance.

Testing. Because it's Guzzle underneath, MockHandler + history middleware works as usual; no network, no Laravel HTTP fake needed:

$mock = new \GuzzleHttp\Handler\MockHandler([
    new \GuzzleHttp\Psr7\Response(200, ['Content-Type' => 'application/json'], json_encode(['id' => 7])),
]);
$history = [];
$stack = \GuzzleHttp\HandlerStack::create($mock);
$stack->push(\GuzzleHttp\Middleware::history($history));

$client = new AcmeClient('token');
// inject: new Zttp(new Client(['handler' => $stack]))

Assert on $history[0]['request']->getHeaderLine('Authorization') and the request body — this is how you catch a broken asJson()/asFormParams() switch before production.

Response accessors. status(), isSuccess() (< 400), isOk() (200), isRedirect(), header('Content-Type'), headers(), body() (raw string), json() (decoded array), object() (decoded to stdClass), effectiveUri() (final URI after redirects — added in v0.6.0), and __toString() for the body.

// v0.6.0+: know where the request actually ended up (post-redirect)
$res = Zttp::withoutRedirecting()->get($url);
report($res->effectiveUri()); // e.g. the OAuth/webhook endpoint it 302'd toward

Gotchas and Tips

It's unmaintained — treat it as a migration candidate. Last release was June 2019 and it pins Guzzle 6.x. On Laravel 7+ you already have Illuminate\Support\Facades\Http, which is the direct descendant of this package (Taylor wrote Zttp, then folded it into core). On Laravel 9/10, which requires Guzzle 7, Composer will simply refuse to install alongside other packages. The API is near-identical, so the port is mostly mechanical:

Zttp Laravel Http
Zttp::withToken($t)->get($u) Http::withToken($t)->get($u)
->asFormParams() ->asForm()
->timeout(5) ->timeout(5)
->retry(3, 200) ->retry(3, 200)
->json() ->json()
isSuccess() successful()
->effectiveUri() $response->effectiveUri()
->throw() (absent) ->throw()

If you're on Laravel 6 or plain PHP with Guzzle 6, Zttp is still perfectly serviceable — just pin kitetail/zttp and be aware there will be no PHP 8.1+ deprecation fixes upstream.

Retry doesn't mean what you probably want. retry($times, $sleepMs) re-attempts when a connection-level exception bubbles out of Guzzle; it does not inspect status codes, so a 503 is returned to you on attempt one. If you need backoff on 5xx, write a small loop yourself and check isSuccess():

foreach (range(1, 3) as $attempt) {
    $res = Zttp::timeout(5)->get($url);
    if ($res->status() < 500) break;
    usleep(200_000 * $attempt);
}

Options are sticky on reused instances. Modifiers mutate $this->options. Static calls are safe (fresh instance each time), but if you inject a shared Zttp object and call ->withToken($a) then later ->withHeaders(...), the old token and headers are still attached. When reusing an instance, always set the full modifier chain per request, or construct a new one.

Header merging is recursive. Zttp merges options with array_merge_recursive. Call withHeaders(['Accept' => 'text/html']) and later withHeaders(['Accept' => 'application/json']) and you can end up sending two Accept values. Set headers once per request rather than incrementally.

No exception on 4xx/5xx — and no exception on bad JSON either. json() is a bare json_decode(...); a stray HTML error page silently becomes null. Always gate on isSuccess() first, and check header('Content-Type') if you're parsing blindly. There is no throw() helper here.

Timeouts. timeout($s) sets the total request timeout only. There's no fluent connect_timeout; use withOptions(['connect_timeout' => 2]) if you need fast failure on DNS/TLS hangs.

Debugging a request that "just returns 500". Temporarily add ->withOptions(['debug' => true]) — Guzzle dumps the full request/response exchange to stderr. Failing that, dd($response->status(), $response->headers(), $response->body()) before parsing anything. Ninety percent of Zttp surprises are "the body wasn't JSON-encoded the way I assumed" (check whether asJson() was actually in the chain) or "auth header got merged wrongly".

Redirects follow by default. Use withoutRedirecting() when you need the raw 302 (OAuth flows, webhook signature verification). effectiveUri() (v0.6.0+) is the reliable way to see where Guzzle actually landed after following redirects — handy in logs and assertion failures, and the correct URL to persist when a signed request gets bounced through a gateway. Likewise TLS verification is on by default — withoutVerifying() is for localhost/self-signed only, never production.

Everything is buffered in memory. body() reads the full response stream. Don't use Zttp for large file downloads; drop to raw Guzzle with sink for those.

No async. Guzzle's promise API isn't exposed; every call is synchronous. For fan-out, use GuzzleHttp\Pool against a raw client, or dispatch queued Laravel jobs.

Extension points, in order of preference: (1) withOptions([...]) to pass any Guzzle option through; (2) constructor injection of your own GuzzleHttp\Client with a custom handler stack; (3) subclass Zttp if you need a domain-specific verb. Avoid forking the package — the whole thing is smaller than the fork's README.

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