Skip to main content

Chapter 6 - The HTTP client beyond the basics

Chapter 6 opens Part III (HTTP, APIs, and Testing), turning from Eloquent's query builder to Laravel's HTTP client. Http::withBasicAuth(), Http::withDigestAuth(), and Http::fake() are the documented tools for authenticating an outgoing request and faking one in a test; this chapter walks through four siblings that sit right next to them and cover ground the official documentation never names by exact method: a third, less common authentication scheme, a way to stream a response straight to disk instead of buffering it in memory, a way to fake a single request instance without touching the rest of a test's HTTP traffic, and a way to see the full, untruncated message of a failed request's exception. Every example is verified against laravel/framework v13.22.0, the corresponding Guzzle release actually installed in this book's companion application (guzzlehttp/guzzle 7.15.2), and the laravel/docs 13.x branch, and is a real, green Pest test drawn from that companion application.

The running scenario for this chapter is an order-management application that needs to pull a PDF invoice for an order from an internal legacy invoicing service - the kind of system that still only speaks NTLM, and that a modern application ends up talking to anyway. App\Support\LegacyInvoicing\LegacyInvoiceClient is the small class this chapter builds up one entry at a time; OrderController::invoice() is the recognizable real-world surface it is called from.

withNtlmAuth()

NTLM (NT LAN Manager) is a challenge-response authentication protocol Microsoft built for Windows networks, long before HTTP APIs were the norm. Unlike Basic auth, it never puts a username and password directly on the wire: the client first sends a bare request, the server answers with a random challenge, and the client replies with a hash computed from that challenge and the user's password hash - a back-and-forth across several requests on the same connection, not a single header. It predates and was largely superseded by Kerberos inside modern Active Directory domains, but plenty of older, standalone intranet systems - internal IIS applications, legacy SOAP or file-serving endpoints, exactly the kind of system this chapter's legacy invoicing service represents - never moved past it and still expect it from any client that wants in. That multi-step handshake is also why NTLM cannot be reduced to setting one header the way Basic auth can: it has to be handled by the transport layer itself, which is exactly what the stability note below is about.

Case type: undocumented method inside Illuminate\Http\Client\PendingRequest's "Authentication" section, which is otherwise well documented - withBasicAuth(), withDigestAuth(), and withToken() all have their own place in laravel/docs. withNtlmAuth($username, $password) sits in the exact same part of the class, with the exact same signature shape, and is never named there.

Alias flag: not an alias - it is a genuinely different authentication scheme, not a rename of Basic or Digest auth.

Stability note - read this before using it in production. withNtlmAuth() only works because Laravel forwards ['user', 'pass', 'ntlm'] straight into Guzzle's auth request option, and Guzzle itself has started retiring that path: as of Guzzle 7.12 (the actual version this book's companion application has installed, 7.15.2, already includes it), passing 'ntlm' this way triggers a deprecation notice, and Guzzle 8.0 will stop applying NTLM through the auth option at all. The same code path also throws an InvalidArgumentException outright if the installed libcurl build has no NTLM support - and NTLM is itself deprecated in curl/libcurl, so that support is not guaranteed to still be there on a given server. None of this is Laravel's doing; it is entirely downstream of a dependency actively moving away from NTLM. Treat withNtlmAuth() as something to verify on the exact environment it will run on, not a drop-in call.

Minimal snippet

The method in isolation, alongside the two documented schemes it sits next to:

Http::withNtlmAuth($username, $password)->get($url);

Documented way vs. discovered way

laravel/docs documents exactly two built-in schemes for this shape of call:

// Basic authentication...
$response = Http::withBasicAuth('taylor@laravel.com', 'secret')->post(/* ... */);

// Digest authentication...
$response = Http::withDigestAuth('taylor@laravel.com', 'secret')->post(/* ... */);

withNtlmAuth() is the third scheme the same class supports, built the same way, just never named on that page:

Http::withNtlmAuth('taylor@laravel.com', 'secret')->post(/* ... */);

All three end up doing the same thing internally - setting $this->options['auth'] to a [$username, $password, $type] triple, with only the third element changing.

Real scenario: authenticating the legacy invoicing client

LegacyInvoiceClient::forOrder() is the one place in this chapter's companion code that talks to the legacy invoicing service, and it authenticates with withNtlmAuth() directly:

class LegacyInvoiceClient
{
public function forOrder(Order $order): Response
{
return Http::baseUrl(config('legacy_invoicing.base_url'))
->withNtlmAuth(
config('legacy_invoicing.username'),
config('legacy_invoicing.password'),
)
->get("/invoices/{$order->uuid}");
}
}

Testing this honestly runs into the same downstream fact behind the stability note above. Http::fake() only replaces the transport layer - it never runs Guzzle's real Client::transfer(), which is the only code that actually turns the auth option into something visible: a real Authorization header for Basic auth, or curl-level options for NTLM and Digest. That means Basic auth shows up on a faked request's headers, but NTLM never does, under a fake or otherwise, because that translation step simply never runs. The test for this entry has to reach for beforeSending() instead - a callback that fires with the request's raw options on every send, faked or real, regardless of what Guzzle would have done with them:

it('sets the auth option to a three-element ntlm array via withNtlmAuth()', function () {
Http::fake();

$capturedOptions = null;

Http::withNtlmAuth('legacy-user', 'legacy-secret')
->beforeSending(function ($request, $options) use (&$capturedOptions) {
$capturedOptions = $options;
})
->get('https://legacy-invoicing.internal/invoices/test-uuid');

expect($capturedOptions['auth'])->toBe(['legacy-user', 'legacy-secret', 'ntlm']);
});

A second test exercises LegacyInvoiceClient itself end to end against the faked service, confirming the whole call still resolves normally:

it('authenticates the legacy invoice request for an order with withNtlmAuth()', function () {
Http::fake([
'legacy-invoicing.internal/*' => Http::response('%PDF-1.4 fake invoice body', 200),
]);

$order = Order::factory()->create();

$response = (new LegacyInvoiceClient)->forOrder($order);

expect($response->status())->toBe(200);

Http::assertSent(fn ($request) => $request->url() === "https://legacy-invoicing.internal/invoices/{$order->uuid}");
});

What this test suite cannot do is prove the Guzzle deprecation notice from the stability note above actually fires - that only happens inside Guzzle's real transfer logic, which a faked request never reaches. That fact is verified directly against the installed guzzlehttp/guzzle source instead, not through a runnable assertion.

sink()

Case type: undocumented method inside Illuminate\Http\Client\PendingRequest. Unlike withNtlmAuth(), there is no partially-documented section to place it next to - laravel/docs has no section at all about saving a response body directly to disk.

Alias flag: none - it changes how the response body is materialized, not a rename of an existing call.

Minimal snippet

sink() accepts either a file path or an already-open stream resource:

Http::sink($path)->get($url);
Http::sink($resource)->get($url);

Documented way vs. discovered way

The documented way to save a response is to fetch the full body in memory first, then write it out:

$response = Http::get($url);
Storage::disk('local')->put('invoice.pdf', $response->body());

sink() does both in the same call, and - this is worth stating plainly, since its name and its internal reuse inside the stub-testing machinery both suggest a testing helper - it is not one: it is a real Guzzle transport option, applied by the actual cURL and stream handlers on genuine, unfaked requests, exactly the kind of thing worth reaching for when the response is a file too large to comfortably hold in memory twice (once as the HTTP response body, once as the file being written):

Http::sink($path)->get($url);

Real scenario: downloading the legacy invoice to disk

LegacyInvoiceClient gained a second method for this entry, downloadForOrder(), sharing the same authenticated request as forOrder() through a small private helper:

class LegacyInvoiceClient
{
public function forOrder(Order $order): Response
{
return $this->request()->get("/invoices/{$order->uuid}");
}

public function downloadForOrder(Order $order, mixed $destination): Response
{
return $this->request()->sink($destination)->get("/invoices/{$order->uuid}");
}

private function request(): PendingRequest
{
return Http::baseUrl(config('legacy_invoicing.base_url'))
->withNtlmAuth(
config('legacy_invoicing.username'),
config('legacy_invoicing.password'),
);
}
}

$destination is passed straight through to sink(), so it accepts either form without any extra handling on this class's part. Two tests confirm both work against a faked response - a string path:

it('streams the invoice directly to a file path via sink()', function () {
$body = str_repeat('%PDF-1.4 fake invoice body ', 100);

Http::fake(['legacy-invoicing.internal/*' => Http::response($body, 200)]);

$order = Order::factory()->create();
$path = tempnam(sys_get_temp_dir(), 'invoice-');

(new LegacyInvoiceClient)->downloadForOrder($order, $path);

expect(file_get_contents($path))->toBe($body);

unlink($path);
});

and an already-open resource:

it('streams the invoice to an open resource via sink()', function () {
$body = str_repeat('%PDF-1.4 fake invoice body ', 100);

Http::fake(['legacy-invoicing.internal/*' => Http::response($body, 200)]);

$order = Order::factory()->create();
$resource = fopen('php://temp', 'w+');

(new LegacyInvoiceClient)->downloadForOrder($order, $resource);

expect(stream_get_contents($resource))->toBe($body);

fclose($resource);
});

Both tests fake the request with Http::fake(), which is worth pausing on: sink() is not test-only, but it still works correctly under a fake. That is not an accident - Http::fake() is itself built on the same stub mechanism this chapter covers as its own entry next (stub()); the handler behind it honors the sink option exactly like a real request would, writing the faked body to the destination given. The next section explains that mechanism directly.

A third test puts the comparison from earlier side by side, on the same faked response, and checks both approaches end up with identical bytes on disk:

it('writes the same bytes as the documented in-memory download, one call instead of two', function () {
$body = str_repeat('%PDF-1.4 fake invoice body ', 100);

// A closure fake, not a pre-built Http::response(), because this test sends two requests
// against the same faked URL: Http::fake() otherwise reuses the exact same PSR-7 stream for
// every match, and the second request would read it already exhausted by the first.
Http::fake(['legacy-invoicing.internal/*' => fn () => Http::response($body, 200)]);

$order = Order::factory()->create();

// Documented way: fetch the full body in memory, then write it out.
$response = (new LegacyInvoiceClient)->forOrder($order);
Storage::disk('local')->put("invoices/{$order->uuid}-manual.pdf", $response->body());

// Discovered way: sink() streams straight to disk in the same call that sends the request.
$sinkPath = tempnam(sys_get_temp_dir(), 'invoice-');
(new LegacyInvoiceClient)->downloadForOrder($order, $sinkPath);

expect(file_get_contents($sinkPath))
->toBe(Storage::disk('local')->get("invoices/{$order->uuid}-manual.pdf"));

unlink($sinkPath);
Storage::disk('local')->delete("invoices/{$order->uuid}-manual.pdf");
});

The closure in that last fake is not incidental: Http::fake() builds the faked response once and reuses the same underlying stream for every request that matches the URL pattern, so a test that sends two requests against the same fake - as this one deliberately does, to compare both approaches on equal footing - needs a fresh response per request, not a single shared one already exhausted by the first read.

stub()

Case type: undocumented method inside Illuminate\Http\Client\PendingRequest's "Testing" area, which is otherwise well documented - Http::fake(), Http::fakeSequence(), and preventStrayRequests()/allowStrayRequests() all have their own place in laravel/docs. stub() is never named there.

Alias flag: none - it solves a narrower problem than Http::fake(), not a rename of it.

Minimal snippet

Http::stub(fn ($request) => Http::response('pong', 200))->get($url);

Documented way vs. discovered way

Http::fake() is the documented way to fake HTTP calls in a test, and it works by storing its fakes on the Factory behind the Http facade - every PendingRequest the Factory builds afterward, anywhere in the application, picks them up automatically. That reach is exactly what makes it able to transparently intercept a call built deep inside a class like LegacyInvoiceClient:

Http::fake(['legacy-invoicing.internal/invoices/*' => Http::response('%PDF-1.4 invoice', 200)]);

stub() works the opposite way: PendingRequest::stub() only ever sets a callback on the one instance it is called on. There is no Factory-level equivalent of it - calling Http::stub(...) builds one fresh PendingRequest and registers the callback on that instance alone. It never reaches into a request some other class builds internally; it only covers a chain you construct and call it on yourself:

Http::baseUrl($baseUrl)->withNtlmAuth($username, $password)
->stub(fn ($request) => Http::response('pong', 200))
->get('/health');

Real scenario: a one-off probe against the legacy invoicing host

A natural place stub() earns its keep: a single ad hoc request against the same host LegacyInvoiceClient talks to - a manual connectivity probe, say - that needs its own response without touching whatever global Http::fake() state the rest of the test already relies on for LegacyInvoiceClient's own traffic. The test builds the probe with the exact same baseUrl()/withNtlmAuth() chain the client uses internally, and proves the two fakes do not interfere with each other:

it('stubs a single ad hoc probe without disturbing the rest of the test\'s global fake', function () {
// Documented way: a global fake, covering LegacyInvoiceClient's normal traffic for the
// whole test.
Http::fake(['legacy-invoicing.internal/invoices/*' => Http::response('%PDF-1.4 normal invoice', 200)]);

$order = Order::factory()->create();

expect((new LegacyInvoiceClient)->forOrder($order)->body())->toBe('%PDF-1.4 normal invoice');

// Discovered way: an ad hoc probe against the same host, built the same way
// LegacyInvoiceClient builds its own request, stubbed on its own - stub() only ever covers
// the exact chain it is called on, it cannot reach into a class's internal request the way
// Http::fake() does.
$probe = Http::baseUrl(config('legacy_invoicing.base_url'))
->withNtlmAuth(config('legacy_invoicing.username'), config('legacy_invoicing.password'))
->stub(fn ($request) => Http::response('pong', 200))
->get('/health');

expect($probe->body())->toBe('pong');

// The global fake is still intact and undisturbed by the stub above.
expect((new LegacyInvoiceClient)->forOrder($order)->body())->toBe('%PDF-1.4 normal invoice');
});

The last assertion is the point of the whole test: after the ad hoc probe runs, the global fake is still exactly as it was, and LegacyInvoiceClient still gets the invoice body it was faked with. Nothing about registering a stub on the probe's own chain touched the Factory's fakes.

stub() also interacts with the documented preventStrayRequests()/allowStrayRequests() pair in a way worth knowing before it surprises anyone: if the stub callback does not intercept a request (returns null) and stray requests are being prevented, the request still throws Illuminate\Http\Client\StrayRequestException, exactly as an entirely unfaked request would:

it('throws StrayRequestException when a stub does not intercept and stray requests are prevented', function () {
Http::preventStrayRequests();

$probe = Http::baseUrl(config('legacy_invoicing.base_url'))
->withNtlmAuth(config('legacy_invoicing.username'), config('legacy_invoicing.password'))
->stub(fn ($request) => null);

expect(fn () => $probe->get('/health'))->toThrow(StrayRequestException::class);
});

A stub that declines to answer is not the same as no stub at all - it still falls through to whatever stray-request policy is in effect.

dontTruncateExceptions()

Case type: undocumented method inside Illuminate\Http\Client\PendingRequest's "Error Handling" area, which is otherwise well documented - throw() and its variants, the default 120-character truncation of RequestException messages, truncateExceptionsAt(), and the global static pair RequestException::truncateAt()/RequestException::dontTruncate() all have their own place in laravel/docs. dontTruncateExceptions() is the one method in that same area never named there.

Alias flag: none - it is a real sibling with genuinely different behavior from truncateExceptionsAt(), not a rename of it, for reasons the real scenario below makes concrete.

Truncation of a failed request's exception message is controlled at three levels, only the innermost of which is this entry:

  • A built-in default of 120 characters (RequestException::$truncateAt).
  • A global override, meant for bootstrap/app.php: RequestException::truncateAt($length) / RequestException::dontTruncate() - both documented.
  • A per-request override on the call itself: Http::truncateExceptionsAt($length) (documented) and Http::dontTruncateExceptions() (not documented).

Minimal snippet

Http::dontTruncateExceptions()->get($url)->throw();

Documented way vs. discovered way

laravel/docs documents the per-request override for shortening the message:

Http::truncateExceptionsAt(240)->post(/* ... */);

dontTruncateExceptions() sits right next to it, undocumented, and is not simply "no length limit" on the same kind of summary. Reading RequestException::prepareMessage() directly shows the two paths produce structurally different messages, not just different lengths: the truncated path (default or truncateExceptionsAt()) calls Guzzle's Message::bodySummary($response, $length) - the first $length characters of the response body only, with a literal (truncated...) suffix appended when the body is longer than that. dontTruncateExceptions() instead calls Message::toString($response) - the entire raw HTTP response, status line and headers included, not merely an untruncated body. Reaching for dontTruncateExceptions() expecting "the same summary, just longer" would be a reasonable guess from the name alone, and it would be wrong.

Headers included is worth pausing on before using this in production. This chapter's own example authenticates with withNtlmAuth(), which never puts credentials in a header - so nothing sensitive leaks into the example below. That is not true of the two documented schemes this chapter compared it against earlier: Basic auth sends Authorization: Basic <base64-encoded-credentials> on every request, and a Bearer token sends Authorization: Bearer <token> the same way. Call dontTruncateExceptions() on a request authenticated either of those ways, and a failure sends that header straight into the exception message - and from there, straight into whatever logs the application writes exceptions to. Reach for it deliberately, not as a default, on any request carrying credentials in a header.

Real scenario: diagnosing a failed legacy invoice request

LegacyInvoiceClient gained a third method for this entry, raising clearly instead of silently returning an error response when the legacy invoicing service fails:

public function forOrderOrFail(Order $order): Response
{
return $this->request()->get("/invoices/{$order->uuid}")->throw();
}

Called as-is, with no truncation method involved, it hits the default: a 500 response with a long diagnostic body gets summarized to its first 120 characters:

it('truncates the exception message to 120 characters by default', function () use ($diagnosticBody) {
Http::fake(['legacy-invoicing.internal/*' => Http::response($diagnosticBody, 500)]);

$order = Order::factory()->create();

try {
(new LegacyInvoiceClient)->forOrderOrFail($order);
$this->fail('Expected a RequestException to be thrown.');
} catch (RequestException $e) {
expect($e->getMessage())
->toContain(substr($diagnosticBody, 0, 120).' (truncated...)')
->not->toContain($diagnosticBody);
}
});

Reaching for dontTruncateExceptions() on the same call shape confirms the distinction from above directly: the message contains the full body, but also the response's status line - not just a longer summary:

it('includes the full raw response via dontTruncateExceptions(), not just an untruncated body', function () use ($diagnosticBody) {
Http::fake(['legacy-invoicing.internal/*' => Http::response($diagnosticBody, 500)]);

$order = Order::factory()->create();

try {
Http::baseUrl(config('legacy_invoicing.base_url'))
->withNtlmAuth(config('legacy_invoicing.username'), config('legacy_invoicing.password'))
->dontTruncateExceptions()
->get("/invoices/{$order->uuid}")
->throw();
$this->fail('Expected a RequestException to be thrown.');
} catch (RequestException $e) {
// Not just an untruncated body: dontTruncateExceptions() switches to the entire raw
// HTTP response (status line and headers included), not merely a longer body summary.
expect($e->getMessage())
->toContain('HTTP/1.1 500')
->toContain($diagnosticBody)
->not->toContain('(truncated...)');
}
});

And the documented sibling, truncateExceptionsAt(), on the same call shape, confirms it stays on the body-summary path, just with a different cutoff:

it('truncates to a custom length via the documented truncateExceptionsAt()', function () use ($diagnosticBody) {
Http::fake(['legacy-invoicing.internal/*' => Http::response($diagnosticBody, 500)]);

$order = Order::factory()->create();

try {
Http::baseUrl(config('legacy_invoicing.base_url'))
->withNtlmAuth(config('legacy_invoicing.username'), config('legacy_invoicing.password'))
->truncateExceptionsAt(240)
->get("/invoices/{$order->uuid}")
->throw();
$this->fail('Expected a RequestException to be thrown.');
} catch (RequestException $e) {
expect($e->getMessage())
->toContain(substr($diagnosticBody, 0, 240).' (truncated...)')
->not->toContain($diagnosticBody);
}
});

Which of the three levels to reach for depends on where the noise actually is: the global bootstrap/app.php override when every request in the application needs the same policy, the per-request truncateExceptionsAt() when only this one call needs a longer summary, and dontTruncateExceptions() specifically when the summary itself is not enough - when the detail that explains a failure lives in a header or in a part of the body past whatever length was already configured.

Summary

EntryDocumented alternativeWhen to prefer it
withNtlmAuth()withBasicAuth() / withDigestAuth()Only when the service truly requires NTLM - verify Guzzle/libcurl support first
sink()Fetch the full body, then Storage::put() / file_put_contents()Downloading a response too large to comfortably hold in memory twice
stub()Http::fake() (global)Faking one ad hoc request built directly in test code, without touching global fake state used elsewhere
dontTruncateExceptions()truncateExceptionsAt($n) / the 120-character defaultThe truncated summary itself hides the detail - it's in a header, or past whatever length is configured

None of these four replace their documented counterpart outright. sink() is the wrong choice the moment the body needs to be reprocessed in memory right away rather than just persisted - fetching it normally is simpler when that is the goal. stub() is the wrong choice whenever Http::fake() already covers the need: it reaches inside a class's own HTTP calls in a way stub() cannot, and reaching for stub() anyway just to fake a single instance adds complexity without buying anything back.

Chapter 6 opened Part III (HTTP, APIs, and Testing) from the request side: authenticating, streaming, faking, and diagnosing failures. Chapter 7 stays in the same Part and turns to the response side - asserting what comes back from a test, whether or not it started as an HTTP response at all.