Die meisten Laravel-Anwendungen, die Geld anfassen, brauchen früher oder später Wechselkurse — ob bei Rechnungen in drei Währungen, beim Zurückrechnen von Stripe-Auszahlungen in die Heimatwährung oder beim Anzeigen lokalisierter Preise im Checkout. Der einfachste Weg ist, eine Laravel-Wechselkurs-API anzustöpseln und FX als gelöstes Problem zu betrachten. Der weniger einfache Teil: es richtig zu machen — aggressiv cachen, damit das Quota nicht durchbrennt; Kurse persistieren, damit ein 5xx vom Upstream nicht den Checkout zerlegt; Refreshes rund um das EZB-Fixing um 16:00 MEZ planen; und Tests schreiben, die nicht ans Netz gehen.
Diese Anleitung geht alles davon durch. Wir bauen einen kleinen Währungsumrechner mit Laravel 11, der Finexly-API, Eloquent für Persistenz, der Cache-Facade für Hot Reads, dem Scheduler für Refresh, einer eigenen Validierungs-Rule für ISO-4217-Codes und Pest mit Http::fake() für Tests. Am Ende hast du eine Service-Class-Architektur, die du in jede Laravel-App ziehen kannst — SaaS-Billing, E-Commerce, Buchhaltung, überall wo Geld eine Grenze überquert.
Warum eine dedizierte Wechselkurs-API hartkodierten Werten überlegen ist
Kurse in einer Config-Datei festzunageln ist die erste falsche Antwort. Bei jedem Request den Upstream zu rufen ist die zweite. Eine vernünftig genutzte Currency-API liefert vier Dinge, die Festwerte nicht können:
- Frische auf Abruf. Kurse bewegen sich während der Marktstunden ständig. Wer Kunden abrechnet, kann auf volatilen Paaren wie USD/JPY oder EUR/TRY mit einem 24 h alten Kurs 1-2 % verlieren — genug, um die Marge eines SaaS-Plans zu killen.
- Breite Abdeckung. Finexly deckt 170+ Währungen inklusive Schwellenmärkten und CBDC-Referenzen ab. Der EZB-Feed, der mit Laravel Swap kommt, deckt etwa 32 Majors ab. Sobald ein einziger Kunde in argentinischen Pesos oder türkischen Lira zahlt, zählt diese Lücke.
- Ein Vertrag. Ein JSON-Format über
latest,historicalundconverthinweg, statt drei verschiedener Upstreams mit Klebeband zusammengeflickt. - Vorhersagbare Rate-Limits. Eine dokumentierte Quota, mit der man rechnen kann, statt „die EZB hat unsere IP gesperrt, weil wir zu oft gepollt haben".
Optionen vergleichen wir ausführlich in unserem Vergleich kostenloser vs. bezahlter Currency-APIs für 2026 und in ExchangeRate-API vs CurrencyLayer vs Finexly.
Projekt-Setup: Laravel 11 + Composer
Dieses Tutorial geht von einer frischen Laravel-11-Installation aus (Laravel 10 läuft identisch; einziger Unterschied ist die bootstrap/app.php aus Schritt 5):
laravel new fx-converter
cd fx-converter
php artisan migrateTrag deinen Finexly-API-Key in die .env ein:
FINEXLY_API_KEY=dein_key_hier
FINEXLY_BASE_URL=https://finexly.com/api/v1
FX_CACHE_TTL=900
FX_DEFAULT_BASE=USD15 Minuten (900 Sekunden) sind ein vernünftiger Cache-TTL für B2B-Billing. Consumer-Checkout will oft 60-300 Sekunden; ein Treasury-Dashboard kann auf 3.600 strecken. Wähle nach dem Risiko, das du auf adverse Kursbewegungen tragen kannst.
Ergänze den Block 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'),
],Noch keinen Key? Bei Finexly registrieren — der Free-Plan gibt dir 1.000 Requests pro Monat, für ein Sideproject oder kleines SaaS reichlich.
Die Klasse ExchangeRateService
Die Regel, die wir durchziehen: Kein Controller, Job oder Blade-View spricht direkt mit Finexly. Alles geht durch eine einzige Service-Class — Mocks im Test sind trivial, und es gibt genau eine Stelle zum Anfassen, wenn man den Provider tauscht oder Fallback-Logik ergänzt.
Lege app/Services/ExchangeRateService.php an:
<?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'];
}
}Ein paar Punkte sind erwähnenswert. retry(2, 250) gibt zwei automatische Wiederholungen mit 250 ms Backoff — das fängt die meisten transienten 502/503 ab, ohne dass du Retry-Code schreibst. timeout(8) ist konservativ: unter 5 Sekunden ist eng, wenn der Upstream auf einem anderen Kontinent steht; über 10 Sekunden zerschießt ein wackeliger Upstream das Latenz-Budget. withToken() setzt den Authorization: Bearer ...-Header, den Finexly erwartet.
Service-Provider-Registrierung
Bind den Service in app/Providers/AppServiceProvider.php, damit DI einfach funktioniert:
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 ist hier richtig — die Klasse hat keinen Per-Request-State, und ein Singleton lässt den darunterliegenden HTTP-Client Verbindungen innerhalb des Requests wiederverwenden.
Kurse cachen mit Laravel Cache (Redis empfohlen)
Der Cache::remember-Aufruf in latest() macht mehr, als es scheint. Mit dem File-Cache-Driver ist das Verhalten korrekt, aber der Cache-Stampede beim Ablauf kann Finexly mit einem Burst treffen. Mit Redis helfen zwei Flags:
// .env
CACHE_STORE=redis
REDIS_CLIENT=phpredisFür die wenigen Endpoints, an denen kein Stampede toleriert wird — Checkout zum Beispiel — wickle den Aufruf 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));
}Nur ein Prozess holt nach; die anderen warten bis zu 3 Sekunden, bis der Cache wieder gefüllt ist. Das vollständige Stampede + Retry-Pattern ist in unserem Caching- und Error-Handling-Guide für Currency-APIs ausführlicher beschrieben.
Persistenz mit Eloquent
Cache ist für heiße Reads. Die Datenbank bringt Dauerhaftigkeit — wenn der Cache kalt wird (Redis-Restart, Deploy, der den Store leert), willst du die jüngsten Kurse ohne Upstream-Round-Trip zur Verfügung haben. Migration generieren:
php artisan make:model ExchangeRate -mEditieren:
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']);
});Die Präzision decimal(18,8) ist wichtig. Kurse als Float speichern schneidet bei JPY-Paaren fraktionale Pips ab und produziert in Skalierung sichtbare Reconcile-Fehler. Acht Nachkommastellen decken jeden veröffentlichten Cross-Kurs.
Das 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);
}
}Füg dem Service eine Methode hinzu, die die Antwort als Snapshot in die Tabelle schreibt — nützlich für Audit und historische Auswertung:
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 umgeht den klassischen „erst SELECT, dann INSERT-or-UPDATE"-Doppel-Round-Trip — ein einziges Statement, idempotent über den Unique Key, perfekt für den täglichen Refresh.
Täglichen Refresh planen
Der Laravel-Scheduler ist die richtige Stelle. Öffne routes/console.php (Laravel 11) oder 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();Das 16:30-MEZ-Fenster greift das EZB-Referenz-Fixing um 16:00 MEZ ab — fünfzehn Minuten Puffer fangen jede Provider-Verzögerung. onOneServer() ist kritisch, wenn mehr als ein Worker läuft; ohne das würde ein horizontales Deploy den Snapshot N-mal anstoßen und das Quota doppelt verbrauchen. Vergiss nicht den Cron auf dem Server:
* * * * * cd /var/www/fx-converter && php artisan schedule:run >> /dev/null 2>&1Der Converter-Endpoint
Route und Controller verdrahten. 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');Der 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(),
],
]);
}
}Eine 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">Umrechnen</button>
</form>
@isset($result)
<p>{{ $result['amount'] }} {{ $result['from'] }} =
{{ $result['converted'] }} {{ $result['to'] }}
(Kurs {{ $result['rate'] }}, Stand {{ $result['as_of'] }})</p>
@endissetVierzig Zeilen Blade und ein Controller — der ganze Converter user-seitig. Wer eine fertige, polierte Version hostet sehen will: unser Währungsumrechner auf finexly.com läuft auf denselben Endpoints.
Eigene Validierungs-Rule für ISO 4217
Hand-Validation mit 'currencies' => 'in:USD,EUR,GBP,...' hält bis zehn Codes und bricht bei 170. Generiere eine ordentliche Rule:
php artisan make:rule IsoCurrencynamespace 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? Cache die lebendige Symbol-Liste:
$symbols = Cache::remember('fx:symbols', 86400, function () use ($fx) {
return array_keys($fx->latest('USD'));
});Und prüfe in_array(strtoupper($value), $symbols, true) in der Rule. Die vollständige Standard-Referenz ist in unserem ISO-4217-Codes-Guide.
API-Fehler, Retries und Rate-Limits
Das Retry/Timeout im Service deckt transiente Aussetzer ab. Für anhaltende Ausfälle willst du eine Fallback-Kette: Cache → DB → Upstream. Ordne latest() neu:
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;
}
}Selbst wenn Finexly eine Stunde 503 liefert, läuft der Checkout mit dem letzten DB-Snapshot weiter. Kombiniere das mit einem Pricing-Tier, der dir Luft am Rate-Limit lässt — auf 95 % Quota zu fahren heißt, beim ersten Traffic-Spike einen 429 zu provozieren.
Für Apps, in denen Millisekunden zählen — Trading-Dashboards etwa — ist das Snapshot-Pattern zu langsam. Dann auf Streaming gehen; die Trade-offs vergleichen wir in REST vs WebSocket für Währungsumrechnung.
Tests mit Pest und Http::fake()
Tests sollten nie die Live-API treffen. Http::fake() macht das trivial. Erstelle 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);
});Mit php artisan test laufen lassen. Drei Tests, vollständige Abdeckung von Happy Path, Validierungspfad und Fallback — und null Netzwerkaufrufe.
Production-Checkliste vor dem Go-Live
Eine kurze Liste von Dingen, die beim ersten Deploy gerne vergessen werden:
- Snapshot in eine Queue. Scheduler-Aufruf in
dispatch(new RefreshRatesJob('USD'))->onQueue('fx')einwickeln, damit ein langsamer Upstream den Scheduler nicht blockiert. - 4xx und 5xx getrennt monitoren. 401 = Key rotiert; 429 = Plan zu klein; 503 = Upstream weg. Drei verschiedene Alerts.
User-Agentsetzen. Anonymer Traffic wird unter Last als erstes deprioritisiert.- Den API-Key nicht loggen.
FINEXLY_API_KEYin die Redaction-Liste inconfig/logging.phpaufnehmen. - Circuit Breaker einbauen. Fünf Fehler in 60 Sekunden → kein Upstream-Aufruf mehr, nur noch Cache, On-Call benachrichtigen.
- Wochenenden mitsnapshotten. Die meisten Provider frieren am Wochenende ein, aber Sa/So-Zeilen in der DB vereinfachen das Monatsende-Reporting.
Häufige Fragen
Auf welche Laravel-Version zielt das Tutorial?
Laravel 11. Der Code läuft unverändert auf Laravel 10; einziger Unterschied ist die Stelle für geplante Tasks (app/Console/Kernel.php in 10, routes/console.php in 11). Laravel 12 (März 2025 erschienen) ist ebenfalls voll kompatibel.
Mit Lumen einsetzbar?
Die Service-Class ist Laravel-frei und portiert direkt. Du musst das Binding manuell registrieren, da Lumen Provider nicht auto-discovert, und die Scheduler-API unterscheidet sich leicht: Lumen\Framework\Console\Scheduling\Schedule benutzen.
Wie oft Kurse aktualisieren? Für B2B-Billing einmal täglich nach dem 16:00-MEZ-EZB-Fixing — das ist Industriestandard. Für B2C-Checkout alle 5-15 Minuten während der Marktstunden. Für Trading oder Treasury-Dashboards Echtzeit-WebSocket — REST-Polling hängt immer hinterher. Trade-offs in unserem Guide zur kostenlosen Wechselkurs-API.
Was passiert, wenn die Upstream-API ausfällt? Mit der gebauten Fallback-Kette serviert der Cache die letzten Kurse bis TTL-Ende, danach die DB den jüngsten Snapshot bis zu 7 Tage, erst dann scheitert der Request. Praktisch übersteht der Checkout jede Same-Day-Störung.
Brauche ich einen Bezahlplan, um dem Tutorial zu folgen? Nein. Finexlys Free-Tier — 1.000 Requests/Monat, ohne Kreditkarte — reicht für jeden Schritt. Mit 15-Minuten-TTL trägt das rund 30.000 Pageviews pro Tag, bevor das Quota greift.
Bereit, Live-Wechselkurse in deine Laravel-App zu stecken? Hol dir deinen kostenlosen Finexly-API-Key — 1.000 Requests/Monat, ohne Kreditkarte, 30 Sekunden. Wenn du das Free-Tier sprengst, starten unsere Pricing-Pläne bei 9 $/Monat und skalieren bis Enterprise. Oder Currency-APIs vergleichen, bevor du dich festlegst.
Explore More
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 →