กลับไปที่บล็อก

How to Build a Currency Converter in Laravel: Complete Exchange Rate API Tutorial (2026)

V
Vlado Grigirov
May 05, 2026
Laravel Currency API Exchange Rates PHP Tutorial Finexly

Most Laravel applications that touch money eventually need exchange rates — whether you're invoicing in three currencies, converting Stripe payouts back to your home base, or showing localized pricing on a checkout page. The simplest approach is to plug in a Laravel currency exchange rate API and treat FX as a solved problem. The not-so-simple part is doing it correctly: caching aggressively so you don't burn your quota, persisting rates so a 5xx from the upstream doesn't take checkout down, scheduling refreshes around the European Central Bank's 16:00 CET fixing, and writing tests that don't hit the network.

This guide walks through all of that. We'll build a small currency converter using Laravel 11, the Finexly API, Eloquent for persistence, the cache facade for hot reads, the scheduler for refreshes, a custom validation rule for ISO 4217 codes, and Pest with Http::fake() for tests. By the end you'll have a service-class architecture you can drop into any Laravel app — SaaS billing, e-commerce, accounting, anywhere money crosses a border.

Why a Dedicated Currency Exchange Rate API Beats Hard-Coded Rates

Hard-coding rates in a config file is the first wrong answer. The second wrong answer is calling the upstream API on every request. A dedicated currency API used the right way gives you four things hard-coded values can't:

  • Freshness on demand. Rates change continuously during weekday market hours. If you're charging customers, even a 24-hour stale rate can move 1–2% on volatile pairs like USD/JPY or EUR/TRY — enough to wipe out a SaaS plan's margin.
  • Coverage breadth. Finexly tracks 170+ currencies including emerging-market and CBDC reference rates. The European Central Bank feed that ships with Laravel Swap covers about 32 majors. If you have a single user paying in Argentine pesos or Turkish lira, that gap matters.
  • A single contract. One JSON shape across latest, historical, and convert endpoints, instead of three different upstream feeds duct-taped together.
  • Predictable rate limits. A documented quota you can reason about, instead of "ECB blocked our IP because we polled too often."

For comparing options, we covered the landscape in detail in our free vs paid currency API comparison for 2026 and ExchangeRate-API vs CurrencyLayer vs Finexly write-ups.

Project Setup: Laravel 11 + Composer

This tutorial assumes a fresh Laravel 11 install (Laravel 10 works identically — the only API difference is the bootstrap/app.php file we'll touch in step 5). Spin one up:

laravel new fx-converter
cd fx-converter
php artisan migrate

Add your Finexly API key to .env:

FINEXLY_API_KEY=your_key_here
FINEXLY_BASE_URL=https://finexly.com/api/v1
FX_CACHE_TTL=900
FX_DEFAULT_BASE=USD

The 15-minute cache TTL (900 seconds) is a sensible default for B2B billing. Consumer-facing checkout sometimes wants 60–300 seconds; a treasury dashboard can stretch to 3,600. Pick what matches your business risk on adverse FX moves.

Add the matching block to config/services.php:

'finexly' => [
    'key' => env('FINEXLY_API_KEY'),
    'base' => env('FINEXLY_BASE_URL', 'https://finexly.com/api/v1'),
    'cache_ttl' => (int) env('FX_CACHE_TTL', 900),
    'default_base' => env('FX_DEFAULT_BASE', 'USD'),
],

Don't have a key yet? Sign up for Finexly — the free plan gives you 1,000 requests per month, plenty for a side project or a small SaaS.

Building the ExchangeRateService Class

The single rule we'll follow throughout: no controller, job, or Blade view ever talks to Finexly directly. Everything goes through one service class, which makes mocking trivial in tests and gives you exactly one place to change when you swap providers or add fallback logic.

Create app/Services/ExchangeRateService.php:

<?php

namespace App\Services;

use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use RuntimeException;

class ExchangeRateService
{
    public function __construct(
        protected string $baseUrl,
        protected string $apiKey,
        protected int $cacheTtl,
        protected string $defaultBase,
    ) {}

    /**
     * Get the latest rate from $from to $to.
     */
    public function rate(string $from, string $to): float
    {
        $from = strtoupper($from);
        $to   = strtoupper($to);

        if ($from === $to) {
            return 1.0;
        }

        $rates = $this->latest($from);
        if (!isset($rates[$to])) {
            throw new RuntimeException("Unsupported currency pair: {$from}->{$to}");
        }

        return (float) $rates[$to];
    }

    /**
     * Convert $amount from $from to $to using cached rates.
     */
    public function convert(float $amount, string $from, string $to): float
    {
        return round($amount * $this->rate($from, $to), 4);
    }

    /**
     * Pull all rates for a base currency, cached.
     */
    public function latest(string $base): array
    {
        $base = strtoupper($base ?: $this->defaultBase);
        $key  = "fx:latest:{$base}";

        return Cache::remember($key, $this->cacheTtl, function () use ($base) {
            $response = Http::withToken($this->apiKey)
                ->acceptJson()
                ->retry(2, 250, throw: false)
                ->timeout(8)
                ->get("{$this->baseUrl}/latest", ['base' => $base]);

            return $this->extractRates($response, $base);
        });
    }

    protected function extractRates(Response $response, string $base): array
    {
        if ($response->failed()) {
            Log::warning('Finexly fetch failed', [
                'base'   => $base,
                'status' => $response->status(),
                'body'   => $response->body(),
            ]);
            throw new RuntimeException("Exchange rate fetch failed: HTTP {$response->status()}");
        }

        $payload = $response->json();
        if (!is_array($payload['rates'] ?? null)) {
            throw new RuntimeException('Malformed exchange rate response');
        }

        return $payload['rates'];
    }
}

A few things worth pointing out. The retry(2, 250) line gives you two automatic retries with a 250 ms backoff, which catches the vast majority of transient 502/503s without you writing any retry code. The timeout(8) is conservative — under 5 seconds is too tight if you ever cross continents to the upstream, and over 10 seconds means a flaky upstream wrecks your request latency budget. withToken() adds the Authorization: Bearer ... header that Finexly expects.

Service Provider Registration

Bind the service in app/Providers/AppServiceProvider.php so dependency injection just works:

use App\Services\ExchangeRateService;

public function register(): void
{
    $this->app->singleton(ExchangeRateService::class, function ($app) {
        $cfg = $app['config']['services.finexly'];
        return new ExchangeRateService(
            baseUrl: $cfg['base'],
            apiKey: $cfg['key'],
            cacheTtl: $cfg['cache_ttl'],
            defaultBase: $cfg['default_base'],
        );
    });
}

Singleton scope is correct here — there's no per-request state inside the class, and using a singleton lets the underlying HTTP client reuse connections across calls in the same request.

Caching Rates with Laravel Cache (Redis Recommended)

The Cache::remember call inside latest() is doing more than it looks. With the file cache driver, you'll get correct behavior but the cache stampede on expiry can hit Finexly with a burst. With Redis, two flags help:

// .env
CACHE_STORE=redis
REDIS_CLIENT=phpredis

For the few endpoints where you can't tolerate any stampede — checkout, for instance — wrap the call in Cache::lock:

public function rateWithLock(string $from, string $to): float
{
    $lock = Cache::lock("fx:lock:{$from}:{$to}", 5);
    return $lock->block(3, fn () => $this->rate($from, $to));
}

Only one process can re-fetch the upstream at a time; the rest wait up to 3 seconds for the cache to repopulate. We covered the broader stampede + retry pattern in our currency API caching and error handling guide, which goes deeper on Redis vs Memcached vs the database driver for FX workloads.

Persisting Rates with Eloquent

Cache is for hot reads. The database is for durability — when the cache goes cold (a Redis restart, a deploy that flushes the store), you want the most recent rates available without an upstream round trip. Generate the migration:

php artisan make:model ExchangeRate -m

Edit the migration:

Schema::create('exchange_rates', function (Blueprint $table) {
    $table->id();
    $table->string('base', 3)->index();
    $table->string('quote', 3)->index();
    $table->decimal('rate', 18, 8);
    $table->date('quoted_on')->index();
    $table->timestamps();

    $table->unique(['base', 'quote', 'quoted_on']);
});

The decimal(18,8) precision matters. Storing rates as floats truncates fractional pips on JPY pairs and produces visible reconciliation errors at scale. Eight decimal places covers every published cross rate.

The model:

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class ExchangeRate extends Model
{
    protected $fillable = ['base', 'quote', 'rate', 'quoted_on'];
    protected $casts = [
        'rate' => 'decimal:8',
        'quoted_on' => 'date',
    ];

    public function scopeLatestFor($query, string $base, string $quote)
    {
        return $query->where('base', $base)
                     ->where('quote', $quote)
                     ->orderByDesc('quoted_on')
                     ->limit(1);
    }
}

Add a method to the service that snapshots the response into the table — useful for both audit trails and historical analysis:

public function snapshot(string $base): int
{
    $rates = $this->latest($base);
    $today = now()->toDateString();
    $rows = collect($rates)->map(fn ($v, $k) => [
        'base'       => $base,
        'quote'      => $k,
        'rate'       => $v,
        'quoted_on'  => $today,
        'created_at' => now(),
        'updated_at' => now(),
    ])->values()->all();

    \App\Models\ExchangeRate::upsert(
        $rows,
        ['base', 'quote', 'quoted_on'],
        ['rate', 'updated_at']
    );

    return count($rows);
}

upsert avoids the typical "insert or update" double round trip — single statement, idempotent on the unique key, perfect for the daily refresh below.

Scheduling a Daily Refresh

Laravel's scheduler is the right place to wire the snapshot. Open routes/console.php (Laravel 11) or app/Console/Kernel.php (Laravel 10):

use App\Services\ExchangeRateService;
use Illuminate\Support\Facades\Schedule;

Schedule::call(function () {
    app(ExchangeRateService::class)->snapshot('USD');
    app(ExchangeRateService::class)->snapshot('EUR');
})
->dailyAt('16:30')
->timezone('Europe/Berlin')
->name('fx:snapshot')
->withoutOverlapping()
->onOneServer();

The 16:30 CET window catches the ECB reference fixing at 16:00 CET — fifteen minutes of slack covers any provider lag. onOneServer() is critical if you run more than one queue worker; without it, a horizontal deploy would invoke the snapshot N times and double-spend your API quota. Don't forget to add the cron entry on the server:

* * * * * cd /var/www/fx-converter && php artisan schedule:run >> /dev/null 2>&1

Building the Currency Converter Endpoint

Now wire it to a route and a controller. routes/web.php:

use App\Http\Controllers\ConverterController;

Route::get('/convert', [ConverterController::class, 'show'])->name('convert.show');
Route::post('/convert', [ConverterController::class, 'convert'])->name('convert.do');

The controller:

namespace App\Http\Controllers;

use App\Rules\IsoCurrency;
use App\Services\ExchangeRateService;
use Illuminate\Http\Request;

class ConverterController extends Controller
{
    public function show()
    {
        return view('convert');
    }

    public function convert(Request $request, ExchangeRateService $fx)
    {
        $data = $request->validate([
            'amount' => ['required', 'numeric', 'min:0', 'max:1000000000'],
            'from'   => ['required', new IsoCurrency()],
            'to'     => ['required', new IsoCurrency()],
        ]);

        $converted = $fx->convert(
            (float) $data['amount'],
            $data['from'],
            $data['to'],
        );

        return view('convert', [
            'result' => [
                'from'      => strtoupper($data['from']),
                'to'        => strtoupper($data['to']),
                'amount'    => $data['amount'],
                'converted' => $converted,
                'rate'      => $fx->rate($data['from'], $data['to']),
                'as_of'     => now()->toIso8601String(),
            ],
        ]);
    }
}

A minimal Blade view at resources/views/convert.blade.php:

<form method="post" action="{{ route('convert.do') }}">
    @csrf
    <input name="amount" type="number" step="0.01" required>
    <input name="from" maxlength="3" placeholder="USD" required>
    <input name="to" maxlength="3" placeholder="EUR" required>
    <button type="submit">Convert</button>
</form>

@isset($result)
    <p>{{ $result['amount'] }} {{ $result['from'] }} =
       {{ $result['converted'] }} {{ $result['to'] }}
       (rate {{ $result['rate'] }}, as of {{ $result['as_of'] }})</p>
@endisset

Forty lines of Blade and one controller — the entire user-facing converter. If you'd rather hand users a polished hosted version, our own currency converter at finexly.com is built on the same endpoints.

A Custom Validation Rule for ISO 4217

Hand-validating 'currencies' => 'in:USD,EUR,GBP,...' works for ten codes and breaks for 170. Generate a proper rule:

php artisan make:rule IsoCurrency
namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

class IsoCurrency implements ValidationRule
{
    /**
     * ISO 4217 alphabetic currency codes are 3 uppercase letters.
     * For full code validation, query the /symbols endpoint and cache the list.
     */
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if (!is_string($value) || !preg_match('/^[A-Z]{3}$/', strtoupper($value))) {
            $fail("The :attribute must be a valid 3-letter ISO 4217 currency code.");
        }
    }
}

Want stricter validation? Cache the live symbols list:

$symbols = Cache::remember('fx:symbols', 86400, function () use ($fx) {
    return array_keys($fx->latest('USD'));
});

Then check in_array(strtoupper($value), $symbols, true) inside the rule. We have a complete reference of the standard in our ISO 4217 currency codes guide.

Handling API Errors, Retries, and Rate Limits

The retry/timeout in the service handles transient blips. For sustained outages, you want a fallback chain: cache → database → upstream. Reorder latest():

public function latest(string $base): array
{
    $key = "fx:latest:{$base}";

    if ($cached = Cache::get($key)) {
        return $cached;
    }

    try {
        $rates = $this->fetchUpstream($base);
        Cache::put($key, $rates, $this->cacheTtl);
        return $rates;
    } catch (\Throwable $e) {
        Log::warning('Upstream FX failed, falling back to DB', ['error' => $e->getMessage()]);
        $rates = \App\Models\ExchangeRate::query()
            ->where('base', $base)
            ->where('quoted_on', '>=', now()->subDays(7))
            ->orderByDesc('quoted_on')
            ->get()
            ->groupBy('quote')
            ->map->first()
            ->mapWithKeys(fn ($r, $k) => [$k => (float) $r->rate])
            ->all();

        if (empty($rates)) {
            throw $e;
        }
        return $rates;
    }
}

Now, even if Finexly returns 503 for an hour, your checkout keeps working with the most recent snapshot from the database. Pair this with a pricing tier that gives you headroom on the rate limit — running at 95% of your quota is asking for the throttle to flip to 429 the moment traffic spikes.

For applications where milliseconds matter — high-frequency trading dashboards, for instance — the snapshot pattern is too slow. Push to a streaming model instead; we compared the trade-offs in REST vs WebSocket for currency conversion.

Testing with Pest and Http::fake()

Tests should never hit the live API. Http::fake() makes that trivial. Create tests/Feature/ExchangeRateServiceTest.php:

use App\Services\ExchangeRateService;
use Illuminate\Support\Facades\Http;

it('converts USD to EUR using cached rates', function () {
    Http::fake([
        '*finexly.com/api/v1/latest*' => Http::response([
            'base'  => 'USD',
            'rates' => ['EUR' => 0.92, 'GBP' => 0.79, 'JPY' => 154.30],
        ], 200),
    ]);

    $fx = app(ExchangeRateService::class);
    expect($fx->convert(100, 'USD', 'EUR'))->toBe(92.0);
    expect($fx->rate('USD', 'JPY'))->toBe(154.30);
});

it('throws on unsupported pair', function () {
    Http::fake([
        '*' => Http::response(['base' => 'USD', 'rates' => ['EUR' => 0.92]], 200),
    ]);

    $fx = app(ExchangeRateService::class);
    $fx->convert(100, 'USD', 'XXX');
})->throws(RuntimeException::class, 'Unsupported currency pair');

it('falls back to database when upstream fails', function () {
    \App\Models\ExchangeRate::factory()->create([
        'base' => 'USD', 'quote' => 'EUR', 'rate' => 0.91, 'quoted_on' => now(),
    ]);
    Http::fake(['*' => Http::response('boom', 503)]);

    $fx = app(ExchangeRateService::class);
    expect($fx->rate('USD', 'EUR'))->toBe(0.91);
});

Run them with php artisan test. Three tests, full coverage of the happy path, the validation path, and the fallback path — and zero network calls.

Production Checklist Before You Ship

A short list of things people forget on the first deploy:

  • Queue the snapshot. Wrap the schedule call in dispatch(new RefreshRatesJob('USD'))->onQueue('fx') so a slow upstream doesn't block the scheduler.
  • Monitor 4xx and 5xx separately. A 401 means your key rotated; a 429 means you need a bigger plan; a 503 means the upstream is down. Three different alerts.
  • Pin a User-Agent. Anonymous traffic gets deprioritized by most providers under load.
  • Don't log the API key. Add FINEXLY_API_KEY to config/logging.php's redaction list.
  • Set a circuit breaker. Five consecutive failures in 60 seconds → stop calling upstream, serve from cache only, alert the on-call.
  • Snapshot weekends too. Most providers freeze rates over the weekend, but having Saturday and Sunday rows in the database makes month-end reporting trivial.

Frequently Asked Questions

Which Laravel version does this tutorial target? Laravel 11. The code works unchanged on Laravel 10; the only difference is where you register scheduled tasks (app/Console/Kernel.php in 10, routes/console.php in 11). Laravel 12 (released March 2025) is also fully compatible.

Can I use this with Lumen? The service class is dependency-free Laravel and ports directly. You'll need to register the binding manually since Lumen doesn't auto-discover providers, and the scheduler API is slightly different — use Lumen\Framework\Console\Scheduling\Schedule instead.

How often should I refresh exchange rates? For B2B billing, once daily after the ECB 16:00 CET fixing is industry standard. For consumer checkout, every 5–15 minutes during market hours. For trading or treasury dashboards, real-time WebSocket — REST polling will always lag. Our free currency exchange rate API covers the trade-offs in detail.

What happens if the upstream API is down? With the fallback chain we built, the cache serves the last known rates until TTL expiry, then the database serves the most recent snapshot for up to 7 days, then the request fails. In practice, your checkout keeps working through any same-day outage.

Do I need a paid plan to follow this tutorial? No. Finexly's free tier — 1,000 requests/month, no credit card — is enough to follow every step here. With the 15-minute cache TTL, that supports about 30,000 page views per day before you'd hit the quota.


Ready to plug live exchange rates into your Laravel app? Get your free Finexly API key — 1,000 requests per month, no credit card, takes 30 seconds. If you outgrow the free tier, our pricing plans start at $9/month and scale up to enterprise. Or compare currency APIs side by side before you commit.

Vlado Grigirov

Senior Currency Markets Analyst & Financial Strategist

Vlado Grigirov is a senior currency markets analyst and financial strategist with over 14 years of experience in foreign exchange markets, cross-border finance, and currency risk management. He has wo...

View full profile →

แชร์บทความนี้