Terug naar Blog

Een valuta-omzetter bouwen in Laravel: complete tutorial wisselkoers-API (2026)

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

De meeste Laravel-applicaties die met geld werken hebben uiteindelijk wisselkoersen nodig — of je nu in drie valuta's factureert, Stripe-uitbetalingen terugrekent naar je basisvaluta, of gelokaliseerde prijzen toont in de checkout. De simpelste route is een wisselkoers-API voor Laravel aansluiten en FX als opgelost beschouwen. De minder simpele route is het goed doen: agressief cachen zodat je quota niet opbrandt, koersen persisteren zodat een 5xx van de upstream je checkout niet plat legt, refreshes plannen rond de ECB-fixing van 16:00 CET, en tests schrijven die niet aan het netwerk komen.

Deze gids loopt dat allemaal door. We bouwen een kleine omzetter met Laravel 11, de Finexly-API, Eloquent voor persistentie, de Cache-facade voor hot reads, de scheduler voor refreshes, een eigen validatieregel voor ISO 4217-codes en Pest met Http::fake() voor tests. Aan het eind heb je een service-class-architectuur die je in elke Laravel-app kunt droppen — SaaS-billing, e-commerce, boekhouding, overal waar geld een grens passeert.

Waarom een aparte wisselkoers-API beter is dan hardgecodeerde koersen

Koersen vastpinnen in een config-bestand is het eerste foute antwoord. Bij elke request de upstream aanroepen is het tweede. Een goed gebruikte currency-API geeft je vier dingen die vaste waarden niet kunnen:

  • Versheid op aanvraag. Koersen bewegen continu tijdens markttijden. Wie klanten incasseert: zelfs een 24 uur oude koers kan 1-2% bewegen op volatiele paren als USD/JPY of EUR/TRY — genoeg om de marge van een SaaS-plan weg te vagen.
  • Brede dekking. Finexly dekt 170+ valuta's, inclusief opkomende markten en CBDC-referenties. De ECB-feed die met Laravel Swap meekomt dekt circa 32 majors. Eén klant die in Argentijnse pesos of Turkse lira betaalt en het verschil voel je.
  • Eén contract. Eén JSON-vorm over latest, historical en convert heen, in plaats van drie verschillende upstreams aan elkaar geplakt.
  • Voorspelbare rate limits. Een gedocumenteerde quota waarmee je kunt rekenen, in plaats van „de ECB heeft ons IP geblokt omdat we te veel polden".

Voor een vergelijking van opties: zie vergelijking gratis vs betaalde currency-API's voor 2026 en ExchangeRate-API vs CurrencyLayer vs Finexly.

Project-setup: Laravel 11 + Composer

Deze tutorial gaat uit van een schone Laravel 11-installatie (Laravel 10 werkt identiek; enige verschil is de bootstrap/app.php die we in stap 5 raken):

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

Zet je Finexly-API-key in .env:

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

15 minuten (900 seconden) is een redelijke cache-TTL voor B2B-billing. Consumer-checkout wil meestal 60-300 seconden; een treasury-dashboard kan tot 3.600. Kies op basis van het risico dat je accepteert op tegengestelde koersbewegingen.

Voeg het bijbehorende blok toe in 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'),
],

Nog geen key? Meld je aan bij Finexly — het free-plan geeft 1.000 requests per maand, ruim voldoende voor een sideproject of klein SaaS.

De ExchangeRateService-class

De regel die we de hele tutorial aanhouden: geen enkele controller, job of Blade-view praat direct met Finexly. Alles loopt via één service class — daardoor zijn mocks in tests triviaal en is er één plek om te wijzigen als je provider verandert of fallback toevoegt.

Maak 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,
    ) {}

    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];
    }

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

    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'];
    }
}

Een paar dingen verdienen een opmerking. De regel retry(2, 250) geeft je twee automatische retries met 250 ms backoff, wat de overgrote meerderheid van transiënte 502/503's opvangt zonder retry-code te schrijven. timeout(8) is conservatief: onder 5 seconden is krap als je een continent oversteekt naar de upstream, boven 10 zorgt een wankele upstream voor een gesneuveld latency-budget. withToken() zet de header Authorization: Bearer ... die Finexly verwacht.

Service provider registreren

Bind de service in app/Providers/AppServiceProvider.php zodat DI gewoon werkt:

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 hier juist — geen per-request state in de class, en een singleton laat de onderliggende HTTP-client connecties hergebruiken binnen dezelfde request.

Koersen cachen met Laravel Cache (Redis aanbevolen)

De Cache::remember-call in latest() doet meer dan het lijkt. Met de file-driver klopt het gedrag, maar de cache stampede bij vervaltijd kan Finexly in een burst raken. Met Redis helpen twee flags:

// .env
CACHE_STORE=redis
REDIS_CLIENT=phpredis

Voor de paar endpoints waar geen stampede getolereerd wordt — checkout bijvoorbeeld — wikkel de 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));
}

Slechts één proces hertrekt de upstream tegelijk; de rest wacht tot 3 seconden tot de cache opnieuw gevuld is. Het bredere stampede + retry-patroon staat in onze gids voor caching en error handling van currency-API's.

Persisteren met Eloquent

Cache is voor hot reads. Database is voor duurzaamheid — als de cache koud wordt (Redis-restart, deploy die store leegt), wil je de meest recente koersen beschikbaar zonder upstream round trip. Genereer de migration:

php artisan make:model ExchangeRate -m

Inhoud:

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']);
});

De precisie decimal(18,8) is belangrijk. Koersen als float opslaan kapt fractionele pips af op JPY-paren en levert op schaal zichtbare reconcile-fouten op. Acht decimalen dekken elke gepubliceerde cross.

Het 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);
    }
}

Voeg aan de service een methode toe die de respons in de tabel snapshot — handig voor audit en historische analyse:

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 vermijdt de typische dubbele round trip „insert of update" — één statement, idempotent op de unique key, perfect voor de dagelijkse refresh hieronder.

Een dagelijkse refresh plannen

De Laravel-scheduler is de juiste plek voor de snapshot. Open routes/console.php (Laravel 11) of 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();

Het venster 16:30 CET pakt de ECB-referentiefixing van 16:00 CET — vijftien minuten speling vangt elke vertraging van de provider op. onOneServer() is cruciaal als je meer dan één worker draait; zonder zou een horizontale deploy de snapshot N keer aanroepen en je quota dubbel verbruiken. Vergeet de cron op de server niet:

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

Het converter-endpoint

Sluit route en controller aan. 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');

De 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(),
            ],
        ]);
    }
}

Een minimale Blade-view 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">Omzetten</button>
</form>

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

Veertig regels Blade en één controller — de hele converter aan de gebruikerskant. Wil je liever een gepolijste, gehoste versie? Onze eigen valuta-omzetter op finexly.com draait op dezelfde endpoints.

Een eigen validatieregel voor ISO 4217

Met de hand valideren via 'currencies' => 'in:USD,EUR,GBP,...' houdt het tot tien codes vol en breekt bij 170. Genereer een nette regel:

php artisan make:rule IsoCurrency
namespace App\Rules;

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

class IsoCurrency implements ValidationRule
{
    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.");
        }
    }
}

Strenger willen? Cache de live symbols-lijst:

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

En check in_array(strtoupper($value), $symbols, true) in de regel. De volledige standaardreferentie staat in onze ISO 4217-codesgids.

API-fouten, retries en rate limits

De retry/timeout in de service vangt transiënte hikjes op. Voor langere uitval wil je een fallback-keten: cache → DB → upstream. Herorganiseer 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;
    }
}

Zelfs als Finexly een uur 503 terugstuurt blijft je checkout draaien op de meest recente DB-snapshot. Combineer dit met een prijsplan dat marge laat op het rate limit — op 95% van je quota draaien is vragen om een 429 bij de eerste verkeerspiek.

Voor apps waar milliseconden tellen — trading-dashboards bijvoorbeeld — is het snapshot-patroon te traag. Stap over op streaming; we vergeleken de afwegingen in REST vs WebSocket voor valuta-omrekening.

Tests met Pest en Http::fake()

Tests mogen nooit aan de live API komen. Http::fake() maakt dat triviaal. Maak 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);
});

Laat ze lopen met php artisan test. Drie tests, volledige dekking van het happy path, het validatiepad en de fallback — en nul netwerkcalls.

Productie-checklist vóór de release

Een korte lijst dingen die mensen bij de eerste deploy vergeten:

  • Zet de snapshot in een queue. Wikkel de scheduler-call in dispatch(new RefreshRatesJob('USD'))->onQueue('fx') zodat een trage upstream de scheduler niet blokkeert.
  • Monitor 4xx en 5xx apart. 401 betekent key-rotation; 429 betekent een groter plan nodig; 503 betekent upstream down. Drie verschillende alerts.
  • Zet een User-Agent. Anoniem verkeer wordt onder load als eerste gedeprioriteerd door providers.
  • Log de API-key niet. Voeg FINEXLY_API_KEY toe aan de redaction-lijst in config/logging.php.
  • Bouw een circuit breaker. Vijf opeenvolgende fouten in 60 seconden → stop met upstream-calls, serveer alleen uit cache, alarmeer de on-call.
  • Snapshot ook in het weekend. De meeste providers bevriezen koersen in het weekend, maar zaterdag/zondag-rijen in DB maken maandeind-rapportage simpel.

Veelgestelde vragen

Welke Laravel-versie targeten we? Laravel 11. De code draait ongewijzigd op Laravel 10; enige verschil is waar je geplande taken registreert (app/Console/Kernel.php in 10, routes/console.php in 11). Laravel 12 (verschenen maart 2025) is ook volledig compatibel.

Werkt het met Lumen? De service class heeft geen Laravel-specifieke afhankelijkheden en porteert direct. Je moet de binding handmatig registreren omdat Lumen geen providers auto-discovered, en de scheduler-API verschilt licht: gebruik Lumen\Framework\Console\Scheduling\Schedule.

Hoe vaak moet ik koersen verversen? Voor B2B-billing: één keer per dag na de ECB-fixing van 16:00 CET is de standaard. Voor consumer-checkout: elke 5-15 minuten in markttijden. Voor trading- of treasury-dashboards: realtime WebSocket — REST-polling loopt altijd achter. Afwegingen in gids gratis wisselkoers-API.

Wat als de upstream-API uitvalt? Met de gebouwde fallback-keten serveert de cache de laatste koersen tot de TTL afloopt, daarna serveert de DB de meest recente snapshot tot 7 dagen, pas dan faalt de request. In de praktijk overleeft de checkout elke storing op dezelfde dag.

Heb ik een betaald plan nodig om deze tutorial te volgen? Nee. De free tier van Finexly — 1.000 requests/maand, geen creditcard — is genoeg voor elke stap. Met 15-minuten-TTL ondersteunt dat zo'n 30.000 page views per dag voordat je het quota raakt.


Klaar om live wisselkoersen in je Laravel-app te steken? Pak je gratis Finexly-API-key — 1.000 requests/maand, geen creditcard, 30 seconden. Als je het free-niveau ontgroeit beginnen onze prijsplannen bij $9/maand en schalen tot enterprise. Of vergelijk currency-API's naast elkaar voordat je kiest.

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 →