EZRate
Sign in

Laravel Exchange Rate API Tutorial: Live Iranian Rates with Http Client, Redis Cache and a Scheduler

Laravel exchange rate API tutorial: a service with the Http client, retries and timeouts, a Redis cache with fallback, a scheduled warmer and Http::fake tests.

Arian Asadi
· Writer and Market Analyst
Published 4 November 2026, 20:00 · Updated 09:23 · 3 min read
Laravel Exchange Rate API Tutorial
Key takeaways
  • Wrap the EZRate API in a small service class using Laravel’s Http client with timeout and retry.
  • Cache responses in Redis for the freshness window and keep a long-lived last-good copy for outages.
  • A scheduled command warms the cache every minute so no visitor ever waits on the upstream API.

This tutorial builds a clean Laravel exchange rate API integration for live Iranian market rates. You will end up with a service class, a Redis cache with stale fallback, a scheduler that keeps it warm and tests that never hit the network.

i
EZRate API
This guide uses the EZRate currency and gold price API: full documentation, code samples and a free key are on the API page.
★
Short answer
Put the key in .env (EZRATE_KEY), create App\Services\EZRate that calls Http::withHeaders(['X-API-Key' => …])->timeout(10)->retry(2, 300)->get(…), wrap it in Cache::remember for freshness_sec, store a last-good copy for 24 hours and run a scheduled command every minute to refresh it.

Part of the Iranian Rial exchange rate API guide. Persian readers: PHP and Laravel tutorial.

1. Configuration

php
// config/services.php
'ezrate' => [
    'key' => env('EZRATE_KEY'),
    'base' => env('EZRATE_BASE', 'https://api.ezrate.me/v1'),
],

2. The service

php
<?php

namespace App\Services;

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

final class EZRate
{
    private const KEY = 'ezrate:rates';

    public function fetch(array $symbols, string $quote = 'IRT'): array
    {
        $res = Http::baseUrl(config('services.ezrate.base'))
            ->withHeaders(['X-API-Key' => config('services.ezrate.key')])
            ->acceptJson()->timeout(10)
            ->retry(2, 300, fn ($e) => ! $e instanceof RequestException || $e->response->status() >= 500)
            ->get('/rates', ['symbols' => implode(',', $symbols), 'quote' => $quote, 'lang' => 'en'])
            ->throw()->json();

        return [
            'rates' => collect($res['data'])->keyBy('symbol')->all(),
            'meta' => $res['meta'],
        ];
    }

    public function refresh(array $symbols): array
    {
        $data = $this->fetch($symbols);
        $ttl = max(30, (int) ($data['meta']['freshness_sec'] ?? 60));
        Cache::put(self::KEY, $data, $ttl);
        Cache::put(self::KEY.':last', $data, now()->addDay());

        return $data;
    }

    /** cached rates; the last good copy (flagged stale) if the API is unreachable */
    public function rates(array $symbols = ['usd', 'eur', 'aed', 'try', 'g18', 'emami', 'usdt']): array
    {
        if ($hit = Cache::get(self::KEY)) {
            return $hit + ['stale' => false];
        }
        try {
            return $this->refresh($symbols) + ['stale' => false];
        } catch (\Throwable $e) {
            report($e);
            $last = Cache::get(self::KEY.':last');

            return $last ? $last + ['stale' => true] : ['rates' => [], 'meta' => [], 'stale' => true];
        }
    }
}

We do not retry 4xx responses: a 401 or 403 will not fix itself, and a 429 means caching is misconfigured. Codes are listed in the JSON reference.

3. Keep the cache warm

php
// routes/console.php
use App\Services\EZRate;
use Illuminate\Support\Facades\Schedule;

Schedule::call(fn () => app(EZRate::class)->refresh(['usd', 'eur', 'aed', 'try', 'g18', 'emami', 'usdt']))
    ->everyMinute()->name('ezrate-refresh')->withoutOverlapping();

Make sure the scheduler runs (* * * * * php artisan schedule:run). Visitors now always read from Redis.

4. Blade

php
// routes/web.php
Route::get('/rates', fn (App\Services\EZRate $ez) => view('rates', $ez->rates()));
html
{{-- resources/views/rates.blade.php --}}
<table>
  @foreach ($rates as $r)
    <tr>
      <td>{{ $r['name'] }}</td>
      <td>{{ number_format($r['buy']) }}</td>
      <td>{{ number_format($r['sell']) }}</td>
    </tr>
  @endforeach
</table>
<p>{{ $meta['quote'] ?? 'IRT' }} · {{ $meta['as_of'] ?? '' }} @if ($stale) · last known rates @endif</p>

5. Tests without network

php
use App\Services\EZRate;
use Illuminate\Support\Facades\Http;

it('serves the last good copy when the API fails', function () {
    Http::fake(['*' => Http::response(['data' => [['symbol' => 'usd', 'buy' => 1, 'sell' => 2]], 'meta' => ['freshness_sec' => 60]])]);
    app(EZRate::class)->refresh(['usd']);
    cache()->forget('ezrate:rates');

    Http::fake(['*' => Http::response(null, 503)]);
    $out = app(EZRate::class)->rates(['usd']);

    expect($out['stale'])->toBeTrue()->and($out['rates']['usd']['sell'])->toBe(2);
});

Production checklist

  • Redis as the cache store (CACHE_STORE=redis).
  • Key only in .env, never in front-end assets — see securing financial API keys.
  • Alert when the cached copy is older than a few minutes.
  • Log X-RateLimit-Remaining-Day occasionally to watch quota use.
US Dollar265,200Toman−0.45%

Why a service class and a scheduler

Putting the HTTP call in a controller is the quickest start but ties every page view to the upstream API: a slow response slows your page, and traffic spikes multiply your quota use. The service class isolates the integration — one place for the base URL, key, timeouts and retries — and the scheduled refresh decouples users from the API entirely. Pages read from Redis in microseconds, and the API sees roughly one request per minute regardless of traffic.

The last-good copy is what keeps the site honest during outages. Instead of showing errors or zeros, pages keep showing the latest confirmed rates and a “last known” notice. Combine that with an alert when the copy gets older than a few minutes, and you will usually know about an upstream problem before your users do.

Common mistakes

  • Calling the API in a loop for each symbol: request all symbols in one /rates?symbols= call.
  • Retrying 4xx errors: they will not fix themselves; log and alert instead.
  • Storing floats for Toman prices: keep integers and format at display time.
  • Forgetting the unit: keep meta.quote with the cached data and assert it in tests.
  • Running the scheduler on every server: use onOneServer() with a shared cache in multi-server setups.
Laravel Horizon or logs showing the ezrate-refresh task running every minute
The scheduled refresh in action

Using the rates elsewhere in the app

Once the service exists, every part of the application can reuse it. A view composer can share the cached rates with all Blade layouts for a header ticker. A Livewire or Inertia component can poll your own lightweight endpoint every minute. A queued job can reprice products, and an Artisan command can export a daily rate table for accounting. All of them read the same cached snapshot, so the numbers are consistent across the site and the API sees the same single request per minute.

php
// app/Providers/AppServiceProvider.php — share rates with every view
View::composer('layouts.app', fn ($view) => $view->with('ticker', app(\App\Services\EZRate::class)->rates()));

For a JSON endpoint consumed by your own front end, return only the fields the UI needs (symbol, name, buy, sell, change) and add a short Cache-Control header so browsers and CDNs can help absorb traffic.

Related live rates: US dollar rate, 18K gold price, Emami coin price.

How do I call an exchange rate API in Laravel?
Use the Http client with your key in the X-API-Key header, a timeout and limited retries, wrapped in a service class.
How long should I cache rates?
For meta.freshness_sec (usually 60 s), plus a long-lived last-good copy for outages.
Should every request call the API?
No. A scheduled command refreshes Redis every minute and requests read from the cache.
How do I test without hitting the API?
Use Http::fake to simulate success and failure responses.
Arian Asadi
I studied economics and developed an interest in financial markets, exchange rates, and economic developments during my university years. Throughout my professional career, I have focused particularly on foreign exchange markets, gold, international money transfers, and the impact of economic developments on prices. In the articles I write, I strive to present information about exchange rates and financial markets in a clear and accessible way, based on verifiable data and market trends. My goal is to help readers gain a clearer understanding of market conditions before making decisions about buying, selling, or transferring currencies.
All posts by author →

Comments (0)

Comments are shown after review. Sign-in is needed to send.

Related

API & Developers
Flutter Currency Tracker Tutorial (plus React Native): Build an Iranian Gold and FX App
API & Developers
React Currency Converter for Live Toman Rates, with a Secure Backend Proxy
API & Developers
Securing Financial API Keys in Web and Mobile Apps: Proxies, Restrictions and Rotation