Let's Connect

Analytics dashboard with charts and metrics on a screen

Caching third-party API responses in Laravel is the cheapest performance win most apps leave on the table. Every call to a payment provider, a geocoder, a currency feed, or a shipping-rate API is slow (200ms to 2s of network you do not control), rate-limited, and often metered per request, so an uncached call both blocks your response and shows up on next month's invoice. The fix is to read from the cache first and only hit the provider on a miss, keyed by a hash of the request parameters with a TTL you can defend. The traps are staleness and cache stampedes, and both are solvable. This is the pattern I run in production.

What does the default cache-aside pattern look like?

Start with Cache::remember(). Read from cache; on a miss, call the provider and store the result with a TTL. The key must be derived from everything that changes the response, endpoint plus parameters, so two different requests never collide on one cache entry. I hash the params so the key stays short and safe regardless of input length.

app/Services/CurrencyRates.php
<?php

namespace App\Services;

use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;

class CurrencyRates
{
    public function for(string $base, array $symbols): array
    {
        $key = 'fx:' . md5($base . '|' . implode(',', $symbols));

        // Cache for 1 hour. FX rates do not move enough to justify a live call per request.
        return Cache::remember($key, now()->addHour(), function () use ($base, $symbols) {
            return Http::retry(2, 200)
                ->timeout(5)
                ->get(config('services.fx.url') . '/latest', [
                    'base' => $base,
                    'symbols' => implode(',', $symbols),
                ])
                ->throw()
                ->json('rates');
        });
    }
}

Two details that bite people: set an explicit timeout() so a hung provider does not pin a PHP worker for the full default request lifetime, and call throw() so a 5xx from upstream does not get cached as a valid empty payload. If the closure throws, nothing is written and the next request retries. Check your provider's own docs for the exact endpoint, query parameters, and whether an API key is required, then keep those in config rather than hardcoding them. For more on keeping the upstream call itself reliable, the cache-aside idea pairs naturally with the patterns in my Redis caching strategies post.

Should you cache large payloads in Redis or the database?

Redis is the right default, but it is RAM, and RAM is expensive. When the payload is large (a 200KB catalog dump) or long-lived (a daily report you keep for a week), a database-backed cache table is often the better call: disk is cheaper than memory, the entries survive a Redis flush, and you can actually query them when you need to debug what got stored. Laravel ships a database cache driver out of the box.

terminal
# Create the cache table migration (if your app does not already have it), then run it
php artisan make:cache-table
php artisan migrate
config/cache.php
'stores' => [

    // Hot, small, ephemeral keys
    'redis' => [
        'driver' => 'redis',
        'connection' => 'cache',
        'lock_connection' => 'default',
    ],

    // Large or long-lived third-party payloads
    'api_archive' => [
        'driver' => 'database',
        'table' => 'cache',
        'connection' => null,
        'lock_connection' => null,
    ],

],

Set CACHE_STORE in your .env to pick the default store, then target the database store explicitly with Cache::store('api_archive')->remember(...) for the bulky responses. Keep your hot, high-churn keys in Redis and push the archival responses to the database table. Mixing them in one Redis instance is how you end up evicting live session data to make room for a JSON blob nobody reads twice.

Developer reviewing API request and response latency on a laptop
Cache-aside turns a 1-2s upstream round trip into a sub-millisecond cache read on the hot path.

How do you avoid serving stale data and a cache stampede?

Plain Cache::remember() has a sharp edge: the moment a hot key expires, every concurrent request misses at once and they all stampede the provider to rebuild it. On a metered API that is a burst of paid calls and possibly a rate-limit lockout. Cache::flexible() softens this with stale-while-revalidate. You give it a fresh window and a stale window. Inside the fresh window it serves from cache. Past fresh but inside stale, it returns the old value immediately and registers a deferred function to recompute after the response is sent. Only past the stale window does a request block on the upstream.

app/Services/CurrencyRates.php
// Fresh for 1 hour; serve stale for up to 6 hours while the value is
// refreshed by a deferred function after the response is sent. During
// the stale window no user blocks on the slow upstream.
return Cache::flexible($key, [3600, 21600], function () use ($base, $symbols) {
    return Http::retry(2, 200)
        ->timeout(5)
        ->get(config('services.fx.url') . '/latest', [
            'base' => $base,
            'symbols' => implode(',', $symbols),
        ])
        ->throw()
        ->json('rates');
});

Be precise about what flexible() does and does not give you. It refreshes the stale value with a deferred function, but it does not place an atomic lock on the rebuild, so it is not a full stampede guarantee. A genuinely cold key (first request after a deploy, after a cache flush, or any request that arrives past the stale window) still falls through to a blocking recompute, and several concurrent requests in that state can all call the provider. When you need a hard single-recompute guarantee, or you need it around a non-cache side effect, wrap the rebuild in Cache::lock() yourself: one request acquires the lock and rebuilds, and the losers return the last known value instead of piling onto the upstream.

How do you respect the provider's own cache semantics?

Do not invent a TTL when the provider already tells you one. Many APIs return Cache-Control: max-age and an ETag. Honor them: derive your TTL from max-age, and store the ETag so your next refresh can send a conditional If-None-Match request. If the provider replies 304 Not Modified, you skip the payload transfer entirely and just extend the TTL on what you already hold, which is a billed call that returns near-instantly with no body.

  • Read Cache-Control: max-age from the response and use it as your TTL ceiling rather than a number you guessed.
  • Persist the ETag (or Last-Modified) alongside the cached body, then send If-None-Match on revalidation to turn refreshes into cheap 304s.
  • Set TTLs by how often the data actually changes: currency rates hourly, a product catalog daily, a country list essentially forever.
  • Never cache 4xx/5xx responses as if they were valid data; let them expire instantly and retry.
Cache the response you can afford to be slightly wrong about; never cache the call you cannot afford to repeat.Md Raihan Hasan

When should you invalidate instead of waiting for the TTL?

A TTL is a bet that staleness is cheaper than freshness for that window. When the provider offers a webhook, you can stop betting: invalidate the exact keys the moment the source changes. If a payment provider fires a webhook on a subscription update, forget that customer's cached state in the handler and let the next read repopulate it. I route every provider's webhooks through one place, see centralizing payment webhooks in a single Laravel controller, which makes cache busting a one-line concern in the handler.

app/Http/Controllers/ProviderWebhookController.php
public function handle(Request $request): Response
{
    // ... verify the webhook signature first ...

    if ($request->input('type') === 'customer.subscription.updated') {
        $customerId = $request->input('data.object.customer');
        Cache::forget('subscription:' . $customerId);
    }

    return response('', 200);
}

When there is no webhook, you fall back to a TTL you can live with, and that is fine as long as you chose it on purpose. The approach that has paid for itself on every project I run is the layered one: cache-aside as the floor, Cache::flexible() to serve stale instead of blocking, Cache::lock() where a cold key needs a real single-recompute guarantee, conditional requests to make refreshes nearly free, and webhook invalidation wherever the provider supports it. Get those right and a metered, latency-heavy third-party dependency stops being a tax on every request and a line item on your bill.