Almost every modern application needs to talk to something outside itself: a payment gateway, a shipping provider, an SMS service, an internal microservice. For years PHP developers reached for cURL directly or pulled in Guzzle and wired it up by hand. Laravel’s HTTP Client is a fluent wrapper around Guzzle that removes that boilerplate and gives you a clean, expressive, testable API for making outbound requests.
This guide walks through building a real integration the way you’d structure it in production: wrapped in a service class, driven by config, with proper response handling, error handling, and logging.
What Is the HTTP Client?
The HTTP Client is Laravel’s built-in interface for making outbound HTTP requests. It ships with the framework (backed by Guzzle under the hood) and is accessed through the Http facade.
use Illuminate\Support\Facades\Http;
$response = Http::get('https://api.example.com/users');That single line handles creating the client, sending the request, and wrapping the result in a response object you can interrogate. Compared to raw Guzzle, you get a more readable fluent syntax, automatic JSON encoding/decoding, sensible defaults, and first-class testing helpers.
Key things it gives you out of the box:
- Fluent request building: headers, auth, query params, and body chained in one expression.
- Automatic JSON handling: arrays are encoded on send and responses decoded on read.
- Response object: a rich wrapper with status helpers, not just a raw string.
- Retries and timeouts: built-in methods, no manual loops.
- Testability: fake any endpoint without hitting the network.
A Real Use Case
Theory is cheap, so let’s anchor everything to one scenario. Imagine you’re integrating a third-party invoicing service. You need to:
- Create an invoice for a customer.
- Fetch an invoice’s current status.
- Authenticate every request with a secret API key.
- Point at a sandbox URL in development and production URL in live.
The naive version looks like this:
$response = Http::withToken('sk_live_xxxxx')
->post('https://api.invoicer.com/v1/invoices', [
'customer_id' => 1,
'amount' => 150000,
'currency' => 'IDR',
]);
$invoice = $response->json();This works, but the secret is hardcoded, the base URL is repeated everywhere, and there’s no error handling. The rest of this guide refactors it into something you’d actually ship.
Creating a Service Class
Scattering Http:: calls across controllers makes integrations hard to change and impossible to test cleanly. The standard pattern is to wrap each external service in a dedicated class under app/Services.
<?php
namespace App\Services;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
class InvoicerService
{
protected function client(): PendingRequest
{
return Http::baseUrl(config('services.invoicer.base_url'))
->withToken(config('services.invoicer.secret'))
->acceptJson()
->timeout(15);
}
public function createInvoice(array $payload): array
{
return $this->client()
->post('/v1/invoices', $payload)
->json();
}
public function getInvoice(string $id): array
{
return $this->client()
->get("/v1/invoices/{$id}")
->json();
}
}Now controllers depend on InvoicerService, not on the HTTP details. The client() method centralizes the base URL, auth, and defaults so every call shares the same configuration.
Tip: Typing Payloads with DTOs
Passing raw arrays into your service methods works, but it’s fragile. A typo in a key (custmer_id), a missing required field, or a wrong type won't surface until the API rejects the request at runtime. A Data Transfer Object (DTO) fixes this by making the payload a typed, self-validating structure the IDE and PHP can check for you.
Define a readonly DTO for the request:
<?php
namespace App\DataTransferObjects;
class CreateInvoiceData
{
public function __construct(
public readonly int $customerId,
public readonly int $amount,
public readonly string $currency = 'IDR',
) {}
public static function fromArray(array $data): self
{
return new self(
customerId: $data['customer_id'],
amount: $data['amount'],
currency: $data['currency'] ?? 'IDR',
);
}
public function toArray(): array
{
return [
'customer_id' => $this->customerId,
'amount' => $this->amount,
'currency' => $this->currency,
];
}
}Then type the service method against the DTO instead of a loose array:
public function createInvoice(CreateInvoiceData $data): array
{
return $this->client()
->post('/v1/invoices', $data->toArray())
->throw()
->json();
}Now the call site is explicit and impossible to get subtly wrong:
$invoice = $this->invoicer->createInvoice(
new CreateInvoiceData(customerId: 1, amount: 150000)
);The benefits compound with the rest of the guide:
- Type safety: PHP enforces the shape of the payload at the boundary, not the API.
- Autocomplete: your IDE knows every field the request accepts.
- Single source of truth: the payload shape is defined once, not spread across call sites.
- Safe defaults: optional fields (like
currency) get sensible defaults in one place. - Refactor-friendly: rename a field and the type system points you at every usage.
For DTOs with heavier mapping or validation needs, spatie/laravel-data is a popular package that adds casting, validation, and array/request hydration on top of this pattern, but plain readonly classes like the above cover most integrations with zero dependencies.
Storing Secrets and Config Values
Notice the service reads from config('services.invoicer.*') instead of hardcoding anything. Secrets and environment-specific values belong in config, sourced from your .env file.
Add the credentials to .env:
INVOICER_BASE_URL=https://sandbox.api.invoicer.com
INVOICER_SECRET=sk_test_xxxxxxxxxxxxxThen register them in config/services.php, which is the conventional home for third-party credentials in Laravel:
return [
// ... other services
'invoicer' => [
'base_url' => env('INVOICER_BASE_URL', 'https://sandbox.api.invoicer.com'),
'secret' => env('INVOICER_SECRET'),
],
];This keeps secrets out of version control, lets each environment supply its own values, and means switching from sandbox to production is a single .env change. Always read through config() rather than env() directly in your code so config caching (php artisan config:cache) keeps working.
Making REST Requests
With the service and config in place, you can make the full range of REST calls. The HTTP Client maps cleanly to HTTP verbs.
// GET with query parameters
$this->client()->get('/v1/invoices', ['status' => 'pending']);
// POST with a JSON body (arrays are encoded automatically)
$this->client()->post('/v1/invoices', [
'customer_id' => 1,
'amount' => 150000,
'currency' => 'IDR',
]);
// PUT / PATCH to update
$this->client()->patch("/v1/invoices/{$id}", ['status' => 'paid']);
// DELETE
$this->client()->delete("/v1/invoices/{$id}");A few common modifiers worth knowing:
// Send as form-encoded instead of JSON
Http::asForm()->post($url, $payload);
// Send multipart (file upload)
Http::attach('document', $fileContents, 'invoice.pdf')->post($url);
// Custom headers per request
Http::withHeaders(['X-Idempotency-Key' => $key])->post($url, $payload);Handling the Response
Every request returns an Illuminate\Http\Client\Response object, not a raw string. It exposes helpers for reading the body and inspecting the result.
$response = $this->client()->get("/v1/invoices/{$id}");
$response->json(); // decoded array
$response->json('status'); // dot-access a single key
$response->object(); // decoded as stdClass
$response->body(); // raw string body
$response->status(); // HTTP status code, e.g. 200
$response->headers(); // response headersStatus helpers let you branch on the outcome expressively instead of comparing numbers:
$response->successful(); // 200–299
$response->failed(); // 400 or higher
$response->clientError(); // 400–499
$response->serverError(); // 500–599
$response->notFound(); // 404Error Handling
By default the HTTP Client does not throw on 4xx/5xx responses; it returns a response you must inspect. This is a frequent source of silent bugs, where code assumes success and reads json() off an error body.
You have two strategies. The first is explicit checking:
$response = $this->client()->post('/v1/invoices', $payload);
if ($response->failed()) {
// handle the error case
}
return $response->json();The second, often cleaner, is to opt into exceptions with throw(), which raises a RequestException on any 4xx or 5xx:
public function createInvoice(array $payload): array
{
return $this->client()
->post('/v1/invoices', $payload)
->throw()
->json();
}Catch it where you can act on it, and you get the failing response attached to the exception:
use Illuminate\Http\Client\RequestException;
try {
$invoice = $this->invoicer->createInvoice($payload);
} catch (RequestException $e) {
$status = $e->response->status();
$body = $e->response->json();
// map to a domain exception, retry, or surface to the user
}You can also throw conditionally, which is useful when some non-2xx responses are expected (a 404 on a status lookup, say):
$response->throwIf($response->serverError());
$response->throwUnless($response->successful());Adding Timeouts and Retries
Network calls fail. A robust integration sets a timeout so a hanging upstream doesn’t block your request, and retries transient failures with backoff.
protected function client(): PendingRequest
{
return Http::baseUrl(config('services.invoicer.base_url'))
->withToken(config('services.invoicer.secret'))
->acceptJson()
->timeout(15) // max seconds for the whole request
->connectTimeout(5) // max seconds to establish connection
->retry(3, 200); // 3 attempts, 200ms base backoff
}For finer control, retry() accepts a closure to decide whether a given failure is worth retrying (retry on 5xx and connection errors, but not on a 422 validation error):
use Illuminate\Http\Client\ConnectionException;
->retry(3, 200, function ($exception, $request) {
return $exception instanceof ConnectionException
|| $exception->response?->serverError();
});Logging Requests and Responses
For any integration that touches money or external state, you’ll want a record of what was sent and what came back. The cleanest place to log is inside the service, around the call.
use Illuminate\Support\Facades\Log;
public function createInvoice(array $payload): array
{
$response = $this->client()->post('/v1/invoices', $payload);
Log::channel('invoicer')->info('createInvoice', [
'status' => $response->status(),
'payload' => $payload,
'response' => $response->json(),
]);
return $response->throw()->json();
}Define a dedicated log channel in config/logging.php so external-call logs stay separate from your application logs:
'channels' => [
'invoicer' => [
'driver' => 'daily',
'path' => storage_path('logs/invoicer.log'),
'days' => 14,
],
],A critical rule: never log raw secrets. Strip or mask the Authorization header and any sensitive fields before they hit the log. For app-wide visibility you can also register a global middleware that logs every outbound request, but per-service logging keeps the context tighter.
Testing with Http::fake()
The standout feature of the HTTP Client is how easy it makes testing. Http::fake() intercepts outbound requests so your tests never touch the network, returning whatever responses you define.
use Illuminate\Support\Facades\Http;
Http::fake([
'api.invoicer.com/v1/invoices' => Http::response([
'id' => 'inv_123',
'status' => 'pending',
], 201),
]);
$invoice = app(InvoicerService::class)->createInvoice([
'customer_id' => 1,
'amount' => 150000,
'currency' => 'IDR',
]);
$this->assertEquals('inv_123', $invoice['id']);You can also assert that the request was made correctly, checking the URL, method, headers, and payload:
Http::assertSent(function ($request) {
return $request->url() === 'https://api.invoicer.com/v1/invoices'
&& $request->method() === 'POST'
&& $request['amount'] === 150000;
});To test failure paths, fake an error status and confirm your error handling behaves:
Http::fake([
'*' => Http::response(['message' => 'Unauthorized'], 401),
]);
$this->expectException(RequestException::class);
app(InvoicerService::class)->createInvoice($payload);This lets you cover both success and failure branches of every integration without a live sandbox.
Sending Requests in Parallel
When you need to hit several independent endpoints, sending them sequentially wastes time. Http::pool() fires them concurrently and returns all responses at once.
use Illuminate\Http\Client\Pool;
$responses = Http::pool(fn (Pool $pool) => [
$pool->get('https://api.invoicer.com/v1/invoices/inv_1'),
$pool->get('https://api.invoicer.com/v1/invoices/inv_2'),
$pool->get('https://api.invoicer.com/v1/invoices/inv_3'),
]);
$first = $responses[0]->json();You can also name each request in the pool and access responses by key, which keeps things readable when the calls differ:
$responses = Http::pool(fn (Pool $pool) => [
$pool->as('customer')->get('/v1/customers/1'),
$pool->as('invoices')->get('/v1/invoices?customer_id=1'),
]);
$customer = $responses['customer']->json();
$invoices = $responses['invoices']->json();Conclusion
Laravel’s HTTP Client turns outbound requests from cURL boilerplate into clean, expressive code. On its own it’s convenient; combined with a few production patterns it becomes robust.
The arc worth internalizing: wrap each external service in its own class, drive credentials and URLs through config rather than hardcoding them, always handle non-2xx responses explicitly (or opt into throw()), add timeouts and retries for resilience, log every external call with secrets masked, and cover both success and failure with Http::fake().
Follow that structure and your integrations stay easy to change, easy to test, and safe to run against real money and real upstreams.
Комментарии (0)
Пока нет комментариев — будьте первым.