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.


- 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.
.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
// config/services.php
'ezrate' => [
'key' => env('EZRATE_KEY'),
'base' => env('EZRATE_BASE', 'https://api.ezrate.me/v1'),
],2. The service
<?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
// 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
// routes/web.php
Route::get('/rates', fn (App\Services\EZRate $ez) => view('rates', $ez->rates()));{{-- 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
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-Dayoccasionally to watch quota use.
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.quotewith the cached data and assert it in tests. - Running the scheduler on every server: use
onOneServer()with a shared cache in multi-server setups.

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.
// 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.

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


