The calls your app makes to other people's APIs are the latency you can't fix by optimizing your own code, and the failures you can't prevent by writing correct code. Stripe has a slow afternoon, a shipping provider's API hangs, a third party you've never thought about quietly adds 400ms to checkout — and from your monitoring it just looks like "checkout got slower" with no obvious cause inside your app. Tracking outgoing HTTP moves that from "our app is slow" to "this specific downstream is slow," which is the difference between guessing and knowing whose problem it is.
Layer 1 — Http facade events
Laravel's Http client fires three events you can listen to in a service provider:
RequestSending— before the request goes outResponseReceived— after a response arrivesConnectionFailed— when the connection couldn't be established
app/Providers/EventServiceProvider.php
use Illuminate\Http\Client\Events\RequestSending;
use Illuminate\Http\Client\Events\ResponseReceived;
use Illuminate\Http\Client\Events\ConnectionFailed;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
public function boot(): void
{
Event::listen(function (ResponseReceived $event) {
Log::info('Outgoing HTTP', [
'url' => $event->request->url(),
'method' => $event->request->method(),
'status' => $event->response->status(),
'duration_ms' => $event->response->transferStats?->getTransferTime() * 1000,
]);
});
Event::listen(function (ConnectionFailed $event) {
Log::warning('HTTP connection failed', [
'url' => $event->request->url(),
'method' => $event->request->method(),
]);
});
}Good for audit trail. Missing: per-host aggregation. A single log line doesn't tell you Stripe is degraded — you need counts and percentiles over time.
Layer 2 — Guzzle middleware
If you're on Guzzle directly (not Http::), use middleware:
use GuzzleHttp\Client;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
$stack = HandlerStack::create();
$stack->push(Middleware::tap(
function (RequestInterface $request) {
$GLOBALS['http_start'] = microtime(true);
},
function (RequestInterface $request, $options, $response) {
$duration = (microtime(true) - $GLOBALS['http_start']) * 1000;
Log::info('Outgoing', [
'host' => $request->getUri()->getHost(),
'path' => $request->getUri()->getPath(),
'status' => $response->getStatusCode(),
'duration_ms' => round($duration, 2),
]);
}
));
$client = new Client(['handler' => $stack]);Layer 3 — Per-host aggregation
The goal isn't logging every call — it's seeing which downstream services are hurting you. Aggregate by host:
- Request count per host (which services do we depend on most?)
- p95 duration per host (which service is slow?)
- Error rate per host (which service is failing?)
- Failure-to-timeout ratio (is this connection issues or real errors?)
Cheap to do with a outgoing_requests table and a nightly rollup job. Expensive to maintain — the APM path is usually a better tradeoff.
Set timeouts on every outgoing call
Guzzle defaults to no timeout. A hung downstream will hang your PHP worker indefinitely. Always set a timeout:
// Set per-call (recommended for user-facing requests)
Http::timeout(5)
->connectTimeout(2)
->get('https://api.stripe.com/v1/charges');
// Set globally via a macro or service provider
Http::macro('external', fn () => Http::timeout(5)->connectTimeout(2));
// Always use ->throw() when you need to fail loudly
Http::timeout(5)->get($url)->throw();Patterns that reduce outgoing HTTP pain
- Move to async jobs. If you can call an external API in a queued job instead of the request, do it. Users don't wait on webhooks.
- Cache responses. Use Laravel's Cache facade with short TTLs for read-heavy external data (pricing tables, currency rates, etc.).
- Circuit break. When a downstream fails repeatedly, fail fast instead of waiting for every timeout. Libraries like ganyicz/laravel-circuit-breaker or a simple cache-based counter work.
- Parallel calls. Http::pool() fires multiple requests concurrently — turns three sequential 300ms calls into one 300ms window.
- Correlation IDs. Pass X-Request-Id on every outgoing call so downstream logs can be correlated back to the originating request.
THE EASY WAY
NightOwl aggregates every outgoing HTTP call by host
The outgoing-requests dashboard groups every outbound HTTP call by destination host with count, p95 duration, and error rate. Click into a host to see which requests called it and what the responses looked like. Tied back to the Laravel request that fired each call.
composer require nightowl/agent
php artisan nightowl:installFrom $5/month flat. Data in your PostgreSQL.