La maggior parte delle applicazioni Laravel che maneggiano denaro finiscono con il bisogno di tassi di cambio — sia che tu fatturi in tre valute, converta i payout di Stripe nella tua valuta base, o mostri prezzi localizzati al checkout. La via più semplice è collegare un'API tassi di cambio per Laravel e considerare il FX un problema risolto. Quella meno semplice è farlo bene: cachare aggressivamente per non bruciare la quota, persistere i tassi così che un 5xx dall'upstream non butti giù il checkout, schedulare i refresh attorno al fixing della Banca Centrale Europea delle 16:00 CET e scrivere test che non tocchino la rete.
Questa guida copre tutto questo. Costruiremo un piccolo convertitore con Laravel 11, l'API Finexly, Eloquent per la persistenza, la facade Cache per le letture calde, lo scheduler per i refresh, una regola di validazione custom per i codici ISO 4217 e Pest con Http::fake() per i test. Alla fine avrai un'architettura a service class che puoi inserire in qualunque app Laravel — billing SaaS, e-commerce, contabilità, ovunque il denaro attraversi un confine.
Perché un'API di cambio dedicata batte i tassi hardcoded
Mettere i tassi in un file di config è la prima risposta sbagliata. Chiamare l'upstream a ogni richiesta è la seconda. Un'API valutaria dedicata, usata bene, ti dà quattro cose che i valori fissi non possono dare:
- Freschezza on demand. I tassi si muovono di continuo durante le ore di mercato. Se addebiti i clienti, anche un tasso vecchio di 24 ore può muoversi 1-2% su coppie volatili come USD/JPY o EUR/TRY — abbastanza per cancellare il margine di un piano SaaS.
- Copertura ampia. Finexly copre 170+ valute, comprese quelle dei mercati emergenti e i riferimenti CBDC. Il feed BCE che arriva con Laravel Swap copre circa 32 majors. Basta un cliente che paghi in pesos argentini o lire turche perché la differenza pesi.
- Un solo contratto. Stessa forma JSON per
latest,historicaleconvert, invece di tre upstream incollati con il nastro. - Limiti prevedibili. Una quota documentata su cui ragionare, non «la BCE ha bloccato il nostro IP perché facevamo troppo polling».
Per il confronto delle opzioni, abbiamo trattato l'argomento in confronto API valutarie gratis vs a pagamento per il 2026 e in ExchangeRate-API vs CurrencyLayer vs Finexly.
Setup del progetto: Laravel 11 + Composer
Il tutorial assume un'installazione Laravel 11 fresca (Laravel 10 funziona allo stesso modo, l'unica differenza è il bootstrap/app.php che tocchiamo allo step 5):
laravel new fx-converter
cd fx-converter
php artisan migrateAggiungi la tua API key Finexly in .env:
FINEXLY_API_KEY=la_tua_chiave_qui
FINEXLY_BASE_URL=https://finexly.com/api/v1
FX_CACHE_TTL=900
FX_DEFAULT_BASE=USD15 minuti (900 secondi) è un TTL di cache ragionevole per il billing B2B. Il checkout consumer di solito vuole 60-300 secondi; una dashboard di tesoreria può estendersi a 3.600. Scegli in base al rischio che accetti su movimenti avversi.
Aggiungi il blocco 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'),
],Non hai ancora una chiave? Iscriviti a Finexly — il piano free dà 1.000 richieste al mese, sufficienti per un side project o un piccolo SaaS.
La classe ExchangeRateService
La regola che teniamo in tutto il tutorial: nessun controller, job o view Blade parla con Finexly direttamente. Tutto passa da una sola service class — questo rende i mock banali nei test e lascia un solo punto da modificare per cambiare provider o aggiungere fallback.
Crea 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'];
}
}Qualche punto vale la pena segnalarlo. La riga retry(2, 250) ti dà due retry automatici con backoff di 250 ms, sufficienti per assorbire la grande maggioranza di 502/503 transitori senza scrivere logica di retry. timeout(8) è conservativo: sotto 5 secondi è stretto se attraversi continenti fino al provider, sopra 10 un upstream instabile distrugge il budget di latency. withToken() aggiunge l'header Authorization: Bearer ... che Finexly si aspetta.
Registrazione nel service provider
Vincola il servizio in app/Providers/AppServiceProvider.php perché la DI funzioni:
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'],
);
});
}Lo scope singleton è giusto qui — non c'è stato per richiesta nella classe, e un singleton permette al client HTTP sottostante di riutilizzare le connessioni nella stessa richiesta.
Cache dei tassi con Laravel Cache (Redis consigliato)
La chiamata Cache::remember dentro latest() fa di più di quanto sembri. Con il driver file il comportamento è corretto, ma il cache stampede alla scadenza può colpire Finexly a raffica. Con Redis aiutano due flag:
// .env
CACHE_STORE=redis
REDIS_CLIENT=phpredisPer i pochi endpoint dove non puoi tollerare alcuno stampede — il checkout, ad esempio — avvolgi la chiamata 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));
}Solo un processo rifà l'upstream alla volta; gli altri attendono fino a 3 secondi che la cache si riempia. Il pattern stampede + retry più ampio è in guida cache & error handling per API valutarie.
Persistenza con Eloquent
Cache è per le letture calde. Database per la durabilità: quando la cache si raffredda (riavvio Redis, deploy che svuota lo store), vuoi avere i tassi più recenti senza round trip al provider. Genera la migration:
php artisan make:model ExchangeRate -mModifica:
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 precisione decimal(18,8) conta. Salvare i tassi come float tronca i pip frazionari sulle coppie con JPY e produce errori di riconciliazione visibili in volume. Otto decimali coprono qualsiasi cross pubblicato.
Il 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);
}
}Aggiungi al servizio un metodo che fa snapshot della risposta in tabella — utile per audit e analisi storica:
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 evita il classico doppio round trip «inserisci o aggiorna» — singola statement, idempotente sulla chiave unica, perfetta per il refresh quotidiano sotto.
Schedulare un refresh quotidiano
Lo scheduler di Laravel è il posto giusto. Apri routes/console.php (Laravel 11) o 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 finestra delle 16:30 CET cattura il fixing di riferimento BCE delle 16:00 CET — quindici minuti di margine assorbono ogni ritardo del provider. onOneServer() è critico se hai più di un worker; senza, un deploy orizzontale invocherebbe lo snapshot N volte e raddoppierebbe il consumo della quota. Non dimenticare il cron sul server:
* * * * * cd /var/www/fx-converter && php artisan schedule:run >> /dev/null 2>&1L'endpoint del convertitore
Cabla la route e il 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');Il 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(),
],
]);
}
}Una view Blade minima 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">Converti</button>
</form>
@isset($result)
<p>{{ $result['amount'] }} {{ $result['from'] }} =
{{ $result['converted'] }} {{ $result['to'] }}
(tasso {{ $result['rate'] }}, al {{ $result['as_of'] }})</p>
@endissetQuaranta righe di Blade e un controller — l'intero convertitore lato utente. Se preferisci una versione hosted già rifinita, il nostro convertitore di valuta su finexly.com gira sugli stessi endpoint.
Una regola di validazione custom per ISO 4217
Validare a mano con 'currencies' => 'in:USD,EUR,GBP,...' regge dieci codici e crolla a 170. Genera una regola seria:
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.");
}
}
}Vuoi più stretto? Cacha la lista viva dei symbols:
$symbols = Cache::remember('fx:symbols', 86400, function () use ($fx) {
return array_keys($fx->latest('USD'));
});E controlla in_array(strtoupper($value), $symbols, true) nella regola. Riferimento completo dello standard nella guida codici ISO 4217.
Errori API, retry e rate limit
Retry/timeout nel servizio coprono i blip transitori. Per outage prolungati serve una catena di fallback: cache → DB → upstream. Riordina 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;
}
}Anche se Finexly restituisce 503 per un'ora, il tuo checkout continua a girare con lo snapshot più recente da DB. Abbinalo a un piano di prezzo che ti dia margine sul rate limit — viaggiare al 95% della quota è chiedere al throttle di trasformarsi in 429 al primo picco.
Per applicazioni dove i millisecondi contano — dashboard di trading, ad esempio — il pattern snapshot è troppo lento. Passa a streaming; abbiamo confrontato i trade-off in REST vs WebSocket per la conversione di valuta.
Test con Pest e Http::fake()
I test non dovrebbero mai chiamare l'API live. Http::fake() lo rende banale. Crea 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);
});Esegui con php artisan test. Tre test con copertura completa di happy path, validazione e fallback — e zero chiamate di rete.
Checklist di produzione prima del go-live
Una lista breve di cose che si dimenticano al primo deploy:
- Metti lo snapshot in coda. Avvolgi la chiamata dello scheduler in
dispatch(new RefreshRatesJob('USD'))->onQueue('fx')perché un upstream lento non blocchi lo scheduler. - Monitora 4xx e 5xx separatamente. 401 = chiave ruotata; 429 = serve un piano più grande; 503 = upstream giù. Tre alert distinti.
- Fissa uno
User-Agent. Il traffico anonimo è il primo deprioritizzato dai provider sotto carico. - Non loggare l'API key. Aggiungi
FINEXLY_API_KEYalla lista di redaction inconfig/logging.php. - Implementa un circuit breaker. Cinque fallimenti consecutivi in 60 secondi → smetti di chiamare l'upstream, servi solo da cache, avvisa il reperibile.
- Snapshot anche nei weekend. La maggior parte dei provider congela i tassi nel weekend, ma avere righe sabato/domenica in DB semplifica il reporting di fine mese.
Domande frequenti
Per quale versione di Laravel è questo tutorial?
Laravel 11. Il codice gira invariato su Laravel 10; l'unica differenza è dove si registrano i task schedulati (app/Console/Kernel.php in 10, routes/console.php in 11). Anche Laravel 12 (uscito a marzo 2025) è pienamente compatibile.
Si può usare con Lumen?
La service class non ha dipendenze specifiche di Laravel e si porta direttamente. Devi registrare il binding manualmente perché Lumen non auto-discovera i provider, e l'API dello scheduler differisce leggermente: usa Lumen\Framework\Console\Scheduling\Schedule.
Con quale frequenza aggiornare i tassi? Per il billing B2B, una volta al giorno dopo il fixing BCE delle 16:00 CET è lo standard. Per il checkout B2C, ogni 5-15 minuti durante le ore di mercato. Per trading o dashboard di tesoreria, WebSocket realtime — il polling REST sarà sempre in ritardo. I trade-off in guida API gratuita di cambio.
E se l'API upstream cade? Con la catena di fallback costruita, la cache serve gli ultimi tassi fino allo scadere del TTL, poi il DB serve lo snapshot più recente fino a 7 giorni, e solo dopo la richiesta fallisce. In pratica, il tuo checkout sopravvive a qualunque outage in giornata.
Serve un piano a pagamento per seguire il tutorial? No. Il tier free di Finexly — 1.000 richieste/mese, senza carta — basta per ogni passo. Con TTL di 15 minuti regge circa 30.000 page view al giorno prima di toccare la quota.
Pronto a collegare tassi di cambio live alla tua app Laravel? Ottieni la tua API key gratis di Finexly — 1.000 richieste/mese, senza carta, 30 secondi. Se superi il free, i piani di prezzo partono da 9 $/mese e scalano fino a enterprise. O confronta API valutarie fianco a fianco prima di decidere.
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 →