La plupart des applications Laravel qui touchent à l'argent finissent par avoir besoin de taux de change — facturation en trois devises, conversion des reversements Stripe vers la devise base, ou affichage des prix localisés au checkout. La voie la plus simple consiste à brancher une API de taux de change pour Laravel et considérer le FX comme un problème résolu. La voie moins simple, c'est de bien le faire : cacher agressivement pour ne pas griller le quota, persister les taux pour qu'un 5xx en amont ne fasse pas tomber le checkout, planifier les refresh autour du fixing 16:00 CET de la Banque centrale européenne, et écrire des tests qui ne tapent pas le réseau.
Ce guide traverse tout ça. On va construire un petit convertisseur avec Laravel 11, l'API Finexly, Eloquent pour la persistance, la facade Cache pour les lectures à chaud, le scheduler pour les refresh, une règle de validation custom pour les codes ISO 4217, et Pest avec Http::fake() pour les tests. À la fin vous aurez une architecture en service classes que vous pouvez glisser dans n'importe quelle app Laravel — facturation SaaS, e-commerce, comptabilité, partout où l'argent traverse une frontière.
Pourquoi une API de taux dédiée bat les taux codés en dur
Coder en dur des taux dans un fichier de config est la première mauvaise réponse. Appeler le provider à chaque requête est la deuxième. Une API de devises bien utilisée vous donne quatre choses qu'aucune valeur fixe ne peut donner :
- De la fraîcheur à la demande. Les taux bougent en continu pendant les heures de marché. Si vous facturez des clients, même un taux de 24 h peut bouger de 1-2 % sur des paires volatiles comme USD/JPY ou EUR/TRY — de quoi effacer la marge d'un plan SaaS.
- Une couverture large. Finexly couvre plus de 170 devises, y compris des marchés émergents et des références CBDC. Le flux BCE livré avec Laravel Swap couvre environ 32 majeures. Si un seul client paie en peso argentin ou en lire turque, ce manque pèse.
- Un seul contrat. Une seule forme JSON sur
latest,historicaletconvert, plutôt que trois flux différents collés au scotch. - Des limites prévisibles. Un quota documenté que vous pouvez raisonner, plutôt que « la BCE a bloqué notre IP parce qu'on faisait trop de polling ».
Pour comparer les options, on a couvert le sujet en détail dans notre comparaison API de devises gratuites vs payantes 2026 et dans ExchangeRate-API vs CurrencyLayer vs Finexly.
Setup du projet : Laravel 11 + Composer
Ce tutoriel suppose une installation Laravel 11 fraîche (Laravel 10 fonctionne pareil ; la seule différence est le bootstrap/app.php qu'on touche à l'étape 5) :
laravel new fx-converter
cd fx-converter
php artisan migrateAjoutez votre clé API Finexly dans .env :
FINEXLY_API_KEY=votre_cle_ici
FINEXLY_BASE_URL=https://finexly.com/api/v1
FX_CACHE_TTL=900
FX_DEFAULT_BASE=USD15 minutes (900 secondes) est un TTL de cache raisonnable pour la facturation B2B. Un checkout grand public veut souvent 60-300 secondes ; un dashboard de trésorerie peut s'étirer jusqu'à 3 600. Choisissez selon le risque que vous acceptez sur les mouvements adverses.
Ajoutez le bloc dans 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'),
],Pas encore de clé ? Inscrivez-vous chez Finexly — le plan gratuit donne 1 000 requêtes par mois, largement assez pour un side project ou un petit SaaS.
La classe ExchangeRateService
La règle qu'on tient du début à la fin : aucun controller, job ou vue Blade ne parle directement à Finexly. Tout passe par une seule service class, ce qui rend les mocks triviaux dans les tests et laisse un seul point à modifier pour changer de provider ou ajouter un fallback.
Créez 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'];
}
}Quelques points méritent d'être soulignés. La ligne retry(2, 250) vous donne deux retries automatiques avec 250 ms de backoff, ce qui absorbe la grande majorité des 502/503 transitoires sans écrire de logique de retry. Le timeout(8) est conservateur — sous 5 secondes c'est trop juste si vous traversez des continents jusqu'au provider, et au-delà de 10 secondes un upstream instable casse votre budget de latence. withToken() ajoute l'en-tête Authorization: Bearer ... que Finexly attend.
Enregistrement du service provider
Liez le service dans app/Providers/AppServiceProvider.php pour que l'injection de dépendance fonctionne :
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'],
);
});
}Le scope singleton est correct ici — pas d'état par requête dans la classe, et un singleton permet au client HTTP sous-jacent de réutiliser les connexions au sein de la même requête.
Cacher les taux avec Laravel Cache (Redis recommandé)
L'appel Cache::remember dans latest() fait plus qu'il n'en a l'air. Avec le driver fichier, le comportement est correct mais le stampede d'expiration peut frapper Finexly en rafale. Avec Redis, deux flags aident :
// .env
CACHE_STORE=redis
REDIS_CLIENT=phpredisPour les rares endpoints où vous ne pouvez tolérer aucun stampede — le checkout, par exemple — enveloppez l'appel dans 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));
}Un seul process refait l'upstream à la fois ; les autres attendent jusqu'à 3 secondes que le cache se remplisse. On a couvert le pattern stampede + retry plus à fond dans notre guide cache et gestion d'erreurs pour API de devises.
Persister avec Eloquent
Le cache est pour la lecture à chaud. La base de données apporte la durabilité — quand le cache se refroidit (Redis redémarré, deploy qui vide le store), vous voulez les taux les plus récents disponibles sans round trip vers le provider. Générez la migration :
php artisan make:model ExchangeRate -mModifiez-la :
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']);
});La précision decimal(18,8) compte. Stocker les taux comme float tronque les pips fractionnaires sur les paires JPY et produit des erreurs de réconciliation visibles à l'échelle. Huit décimales couvrent tous les cross publiés.
Le modèle :
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);
}
}Ajoutez au service une méthode qui snapshot la réponse en table — utile pour audit et analyse historique :
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 évite le double round trip « insert ou update » classique — une seule statement, idempotente sur la clé unique, parfaite pour le refresh quotidien ci-dessous.
Planifier un refresh quotidien
Le scheduler de Laravel est l'endroit naturel pour le snapshot. Ouvrez routes/console.php (Laravel 11) ou 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();La fenêtre 16:30 CET attrape le fixing de référence BCE de 16:00 CET — quinze minutes de marge couvrent tout retard du provider. onOneServer() est critique si vous tournez plus d'un worker ; sans ça, un déploiement horizontal invoquerait le snapshot N fois et doublerait la consommation de quota. N'oubliez pas la cron sur le serveur :
* * * * * cd /var/www/fx-converter && php artisan schedule:run >> /dev/null 2>&1L'endpoint du convertisseur
Branchez la route et le 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');Le 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(),
],
]);
}
}Une vue Blade minimale 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">Convertir</button>
</form>
@isset($result)
<p>{{ $result['amount'] }} {{ $result['from'] }} =
{{ $result['converted'] }} {{ $result['to'] }}
(taux {{ $result['rate'] }}, à la date {{ $result['as_of'] }})</p>
@endissetQuarante lignes de Blade et un controller — tout le convertisseur côté utilisateur. Si vous préférez une version hébergée et léchée, notre convertisseur de devises sur finexly.com tourne sur les mêmes endpoints.
Une règle de validation custom pour ISO 4217
Valider à la main avec 'currencies' => 'in:USD,EUR,GBP,...' tient pour dix codes et casse à 170. Générez une vraie règle :
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.");
}
}
}Vous voulez plus strict ? Mettez en cache la liste vivante des symboles :
$symbols = Cache::remember('fx:symbols', 86400, function () use ($fx) {
return array_keys($fx->latest('USD'));
});Puis vérifiez in_array(strtoupper($value), $symbols, true) dans la règle. La référence complète du standard est dans notre guide ISO 4217.
Erreurs API, retries et rate limits
Le retry/timeout du service gère les blips transitoires. Pour les pannes prolongées, on veut une chaîne de fallback : cache → base → upstream. Réorganisez 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;
}
}Même si Finexly renvoie 503 pendant une heure, votre checkout continue avec le snapshot le plus récent en base. Combinez avec un tarif qui vous laisse de la marge sur le rate limit — tourner à 95 % du quota, c'est inviter le throttle à passer en 429 dès le premier pic.
Pour des applis où la milliseconde compte — dashboards de trading, par exemple — le pattern snapshot est trop lent. Passez en streaming ; on a comparé les arbitrages dans REST vs WebSocket pour la conversion de devises.
Tests avec Pest et Http::fake()
Les tests ne devraient jamais taper l'API live. Http::fake() rend ça trivial. Créez 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);
});Lancez avec php artisan test. Trois tests, couverture complète du happy path, du chemin de validation et du fallback — et zéro appel réseau.
Checklist de production avant la mise en ligne
Une courte liste de choses qu'on oublie au premier déploiement :
- Mettez le snapshot en queue. Enveloppez l'appel du scheduler dans
dispatch(new RefreshRatesJob('USD'))->onQueue('fx')pour qu'un upstream lent ne bloque pas le scheduler. - Surveillez 4xx et 5xx séparément. 401 = clé tournée ; 429 = il faut un plus gros plan ; 503 = upstream HS. Trois alertes différentes.
- Fixez un
User-Agent. Le trafic anonyme est le premier dépriorisé par les providers sous charge. - Ne loggez pas la clé API. Ajoutez
FINEXLY_API_KEYà la liste de redaction deconfig/logging.php. - Implémentez un circuit breaker. Cinq échecs d'affilée en 60 secondes → on arrête d'appeler l'upstream, on sert depuis le cache, on alerte l'astreinte.
- Snapshot le week-end aussi. La plupart des providers gèlent les taux le week-end, mais avoir des lignes samedi/dimanche en base simplifie le reporting de fin de mois.
Foire aux questions
Quelle version de Laravel vise ce tutoriel ?
Laravel 11. Le code marche tel quel sur Laravel 10 ; la seule différence est l'endroit où l'on enregistre les tâches planifiées (app/Console/Kernel.php en 10, routes/console.php en 11). Laravel 12 (sorti en mars 2025) est aussi totalement compatible.
Utilisable avec Lumen ?
La service class n'a pas de dépendance Laravel-spécifique et se porte directement. Vous devrez enregistrer le binding à la main car Lumen n'auto-découvre pas les providers, et l'API du scheduler diffère légèrement : utilisez Lumen\Framework\Console\Scheduling\Schedule.
À quelle fréquence rafraîchir les taux ? Pour la facturation B2B, une fois par jour après le fixing 16:00 CET de la BCE est le standard. Pour le checkout B2C, toutes les 5-15 minutes en heures de marché. Pour trading ou trésorerie, WebSocket en temps réel — le polling REST aura toujours du retard. Notre guide API gratuite de change couvre les arbitrages.
Et si l'API upstream tombe ? Avec la chaîne de fallback construite, le cache sert les derniers taux jusqu'à expiration du TTL, puis la base sert le snapshot le plus récent jusqu'à 7 jours, puis seulement la requête échoue. En pratique, votre checkout survit à toute panne dans la journée.
Faut-il un plan payant pour suivre ce tutoriel ? Non. Le tier gratuit Finexly — 1 000 requêtes/mois, sans carte — suffit pour toutes les étapes. Avec un TTL de 15 minutes, ça supporte environ 30 000 vues par jour avant de toucher le quota.
Prêt à brancher des taux en temps réel dans votre app Laravel ? Récupérez votre clé API Finexly gratuite — 1 000 requêtes/mois, sans carte, 30 secondes. Si vous dépassez le free, nos tarifs commencent à 9 $/mois et montent jusqu'à enterprise. Ou comparez les API de devises côte à côte avant de choisir.
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 →