EUR/USD 1.15827 -0.61% GBP/USD 1.35275 +0.0% USD/JPY 160.099 +0.0% USD/CHF 0.80911 +0.0% AUD/USD 0.71596 +0.0% USD/CAD 1.39020 +0.0% NZD/USD 0.59105 +0.0% BTC/USD 77,577.80 -0.32% ETH/USD 2,437.44 -0.2% · EUR/USD 1.15827 -0.61% GBP/USD 1.35275 +0.0% USD/JPY 160.099 +0.0% USD/CHF 0.80911 +0.0% AUD/USD 0.71596 +0.0% USD/CAD 1.39020 +0.0% NZD/USD 0.59105 +0.0% BTC/USD 77,577.80 -0.32% ETH/USD 2,437.44 -0.2% ·
8 min read currency rates api php

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.

Currency Rates API in PHP: A Laravel Integration Guide with Caching

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::remember TTL 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.