Currency Rates API in PHP: A Laravel Integration Guide with Caching
From a plain cURL call to a cached Laravel service and a scheduled refresh command - plus how to keep prices on the page when the upstream API stalls.
A dashboard that quotes EUR/USD three seconds late is a support ticket. A pricing page that quotes it three hours late is a refund request. Most PHP teams meet this problem the same way: the first integration works perfectly in staging, then production traffic multiplies upstream calls by every page view, the provider's rate limiter starts returning 429s, and prices vanish from the page.
The fix is not a faster HTTP client. It is deciding deliberately where the rate lives between the provider and the browser. This guide walks through a plain-PHP cURL call with correct timeouts, a Laravel service class backed by Cache::remember, a scheduled refresh command, and the failure handling that keeps numbers on screen when the upstream API has a bad minute.
Key takeaways
- Fetch on a schedule, not per request - page views and quote freshness are unrelated concerns.
- A
Cache::rememberTTL of 5-30 seconds absorbs traffic spikes without noticeably ageing the price.- Keep a separate last-good snapshot on a long TTL so an outage degrades to stale prices instead of exceptions.
- Laravel 11 added sub-minute scheduling, so a warm cache costs one line in
routes/console.php.- Persist to the database only when history, auditing, or reconciliation is actually required.
Start with plain PHP: one cURL call, correct timeouts
Before any framework abstraction, it helps to see the raw call. The Live-Rates endpoint takes an API key and returns a JSON array of pairs, each with the quote fields a front end needs. The important part below is not the request - it is the two timeout options and the explicit error path.
<?php
function fetch_rates(string $apiKey): array
{
$url = 'https://www.live-rates.com/api/rates?api_key=' . urlencode($apiKey);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 2,
CURLOPT_TIMEOUT => 5,
CURLOPT_ENCODING => 'gzip',
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($body === false || $status !== 200) {
throw new RuntimeException('Rate fetch failed: ' . ($error ?: 'HTTP ' . $status));
}
return json_decode($body, true, 512, JSON_THROW_ON_ERROR);
}
$rates = fetch_rates(getenv('LIVERATES_KEY'));
foreach ($rates as $row) {
if ($row['currency'] === 'EURUSD') {
printf("EUR/USD bid %s ask %s\n", $row['bid'], $row['ask']);
}
}
Timeouts are the whole game
cURL's default connect and read timeouts are effectively unbounded. A single slow upstream socket then holds a PHP-FPM worker until the process manager runs out of children, and an unrelated marketing page starts timing out. Two seconds to connect and five seconds total is generous for a JSON endpoint; anything slower should be treated as a failure and served from cache. Always decode with JSON_THROW_ON_ERROR so a truncated body raises instead of silently becoming null.
A Laravel service class with Cache::remember
In Laravel, wrap the call in a small singleton service rather than scattering Http::get() through controllers. One class means one place to change the TTL, one place to add logging, and one seam to fake in tests.
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class LiveRates
{
private const KEY = 'liverates:snapshot';
private const LAST_GOOD = 'liverates:last_good';
private const FRESH_TTL = 10; // seconds
private const FALLBACK_TTL = 86400; // seconds
/** Cheap read path: safe to call many times per request. */
public function all(): array
{
return Cache::remember(self::KEY, self::FRESH_TTL, function () {
try {
return $this->pull();
} catch (\Throwable $e) {
Log::warning('live-rates fetch failed', ['error' => $e->getMessage()]);
return Cache::get(self::LAST_GOOD, []);
}
});
}
public function pair(string $symbol): ?array
{
foreach ($this->all() as $row) {
if (strcasecmp($row['currency'] ?? '', $symbol) === 0) {
return $row;
}
}
return null;
}
/** Network path: called by the scheduler, or as a cache miss of last resort. */
public function pull(): array
{
$rows = Http::timeout(5)
->connectTimeout(2)
->retry(2, 200)
->acceptJson()
->get('https://www.live-rates.com/api/rates', [
'api_key' => config('services.liverates.key'),
])
->throw()
->json();
Cache::put(self::LAST_GOOD, $rows, self::FALLBACK_TTL);
return $rows;
}
}
Two caches with two lifetimes do the real work. liverates:snapshot is the hot, short-lived copy every request reads. liverates:last_good is written only after a successful response and lives for a day, so it is still there when the network is not. Register the key in config/services.php and read it from .env - never hardcode it into a service class that ends up in version control.
Store the rates or fetch them per request?
This is the decision that determines whether the integration scales. Fetching inside the request cycle ties upstream call volume to traffic, which is exactly backwards: 10,000 visitors in a minute do not need 10,000 quotes, they need one quote delivered 10,000 times. Live-Rates throttles by API key - roughly 400 requests per five-minute window - so an uncached integration on a busy page hits the limit long before it hits a bandwidth problem.
| Strategy | Upstream calls | Worst-case staleness | Survives an outage |
|---|---|---|---|
| Fetch per request | One per page view | Near zero | No |
Cache::remember, 10s TTL |
Up to 6 per minute | 10 seconds | Only with a fallback copy |
| Scheduled refresh, cache read | Fixed, e.g. 6 per minute | Refresh interval | Yes, via last-good |
| Scheduled refresh, database row | Fixed | Refresh interval | Yes, survives a cache flush |
For display purposes - a rates table, a converter widget, a checkout line item - cache is sufficient and a database write per tick is waste. Persist to MySQL or Postgres when the business needs a defensible record: the rate applied to an invoice, an end-of-day close for reporting, or a backfill for charts. In that case store the payload plus the provider timestamp, and treat the row as an immutable audit record rather than a mutable cache.
Keeping the cache warm with a scheduled command
Relying on a cache miss to trigger the fetch means one unlucky visitor per TTL pays the full network latency. A scheduled command moves that cost off the request path entirely.
<?php
namespace App\Console\Commands;
use App\Services\LiveRates;
use Illuminate\Console\Command;
class RefreshRates extends Command
{
protected $signature = 'rates:refresh';
protected $description = 'Pull the latest FX snapshot into the cache';
public function handle(LiveRates $rates): int
{
try {
$rows = $rates->pull();
} catch (\Throwable $e) {
$this->error('refresh failed: ' . $e->getMessage());
return self::FAILURE; // last-good copy stays in place
}
$this->info(count($rows) . ' pairs refreshed');
return self::SUCCESS;
}
}
Schedule it in routes/console.php. Laravel 11, released in March 2024, added sub-minute frequencies, so a ten-second cadence no longer needs a hand-rolled daemon:
use Illuminate\Support\Facades\Schedule;
Schedule::command('rates:refresh')
->everyTenSeconds()
->withoutOverlapping()
->runInBackground();
withoutOverlapping() matters more than it looks: without it, a slow upstream response lets runs pile up and multiply the request rate against the throttle. Note that sub-minute tasks keep the scheduler process resident, so ensure it is supervised by systemd or Supervisor rather than a bare cron entry.
Handling API downtime without breaking the page
Degrade, don't fail
Every provider has a bad minute eventually - a deploy, a peering issue, a DNS blip. The service class above already answers correctly: a failed pull logs a warning and returns the last-good snapshot, so the page renders slightly stale prices instead of a 500. If even the fallback is empty, render the widget in an explicit "rates unavailable" state rather than printing zeros, which look like real quotes and are far more damaging than a blank.
Show the timestamp
Any surface displaying a stale price should say how stale it is. Render the provider's timestamp next to the number, and mark the value visually once it exceeds a threshold. Finance users tolerate a delay they can see; they do not tolerate a number they cannot date. Pair this with a health check that alerts when the last successful pull is older than a few minutes - a silently frozen cache is the failure mode that survives longest in production.
Back off instead of hammering
When failures repeat, stop retrying at full rate. A simple counter in the cache that widens the refresh interval after three consecutive errors - and resets on the first success - avoids turning a provider incident into a self-inflicted rate-limit ban. Laravel's retry(2, 200) handles transient blips; the backoff handles sustained ones.
FAQ
Should exchange rates be cached or fetched on every request?
Cached, in almost every case. Upstream call volume should track how fast prices change, not how much traffic the site gets, and a short TTL keeps quotes fresh while decoupling the two.
What TTL works for Cache::remember with forex data?
Five to thirty seconds suits dashboards, converters, and pricing pages. Trading-adjacent surfaces should combine a scheduled refresh with a streaming or polling front end rather than shortening the TTL toward zero.
What should a Laravel app do when the rates API is unreachable?
Serve the last successful snapshot from a long-lived cache key, log the failure, and display the quote timestamp so users can judge the staleness themselves. Throwing an exception into the request cycle is the worst available option.
Is a database table needed, or is the cache enough?
Cache alone is enough for display. Add a database table when a rate must be reproducible later - invoices, reconciliation, historical charts - and store the provider timestamp alongside the value.
Can this run without Laravel?
Yes. The plain cURL function works in any PHP 8 project; substitute APCu, Redis, or a flat file for Cache::remember and a cron entry for the scheduler. The two-lifetime pattern is framework-independent.
Ready to wire this up against live data? Browse the full list of supported pairs, check a single instrument such as the EUR/USD live rate, then pick a tier on the Live-Rates plans page to get an API key and drop the service class straight into an existing Laravel app.
Real-time forex rates for your app
Live bid/ask for the pairs you need, updated every second, with a simple JSON & XML API. Try it free for 7 days.