La mayoría de las aplicaciones Laravel que manejan dinero acaban necesitando tipos de cambio: ya sea facturando en tres divisas, convirtiendo pagos de Stripe a tu moneda base o mostrando precios localizados en el checkout. Lo más sencillo es enchufar una API de tipos de cambio para Laravel y dar la conversión por resuelta. Lo no tan sencillo es hacerlo bien: cachear con agresividad para no quemar la cuota, persistir tipos para que un 5xx del proveedor no tire el checkout, programar los refrescos en torno al fixing del Banco Central Europeo a las 16:00 CET y escribir tests que no toquen la red.
Esta guía recorre todo eso. Construiremos un pequeño conversor de divisas usando Laravel 11, la API de Finexly, Eloquent para persistir, la facade Cache para lecturas calientes, el scheduler para refrescos, una regla de validación a medida para códigos ISO 4217 y Pest con Http::fake() para los tests. Al final tendrás una arquitectura basada en service classes que puedes copiar a cualquier aplicación Laravel: facturación SaaS, e-commerce, contabilidad, cualquier sitio donde el dinero cruce una frontera.
Por qué una API dedicada de tipos de cambio supera a los valores hardcodeados
Hardcodear tipos en un fichero de config es la primera respuesta equivocada. La segunda es llamar al proveedor en cada request. Una API de divisas bien usada te da cuatro cosas que los valores fijos no:
- Frescura bajo demanda. Los tipos se mueven de forma continua durante el horario de mercado. Si cobras a clientes, incluso un tipo desactualizado 24 horas puede moverse 1-2% en pares volátiles como USD/JPY o EUR/TRY: suficiente para borrar el margen de un plan SaaS.
- Cobertura amplia. Finexly cubre más de 170 divisas, incluidas las de mercados emergentes y referencias CBDC. El feed del BCE que viene con Laravel Swap cubre unas 32 divisas mayores. Si tienes un solo cliente pagando en pesos argentinos o liras turcas, esa diferencia importa.
- Un único contrato. Una sola forma JSON para
latest,historicalyconvert, en lugar de tres feeds distintos pegados con cinta adhesiva. - Límites de tasa predecibles. Una cuota documentada con la que puedes razonar, en vez del clásico "el BCE bloqueó nuestra IP por hacer polling demasiado rápido".
Para comparar opciones, lo cubrimos en detalle en nuestra comparación de APIs de divisas gratis vs de pago para 2026 y en ExchangeRate-API vs CurrencyLayer vs Finexly.
Configuración del proyecto: Laravel 11 + Composer
Este tutorial asume una instalación limpia de Laravel 11 (Laravel 10 funciona igual; la única diferencia es el fichero bootstrap/app.php que tocamos en el paso 5). Crea uno:
laravel new fx-converter
cd fx-converter
php artisan migrateAñade tu API key de Finexly al .env:
FINEXLY_API_KEY=tu_clave_aqui
FINEXLY_BASE_URL=https://finexly.com/api/v1
FX_CACHE_TTL=900
FX_DEFAULT_BASE=USDEl TTL de 15 minutos (900 segundos) es un valor por defecto razonable para facturación B2B. Un checkout de cara al consumidor suele querer 60-300 segundos; un dashboard de tesorería puede estirarse a 3.600. Elige según el riesgo que aceptes ante movimientos adversos.
Añade el bloque correspondiente en 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'),
],¿Aún no tienes clave? Regístrate en Finexly: el plan gratis te da 1.000 peticiones al mes, suficiente para un side project o un SaaS pequeño.
La clase ExchangeRateService
La regla que seguiremos: ningún controlador, job o vista Blade habla con Finexly directamente. Todo pasa por una sola service class, lo que hace los mocks triviales en tests y deja un único punto donde cambiar de proveedor o añadir lógica de 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'];
}
}Algunas cosas que merece la pena destacar. La línea retry(2, 250) te da dos reintentos automáticos con 250 ms de backoff, que cubre la inmensa mayoría de 502/503 transitorios sin que escribas lógica de reintento. El timeout(8) es conservador: bajar de 5 segundos es agresivo si cruzas continentes hasta el proveedor, y subir de 10 segundos hace que un upstream inestable destroce tu presupuesto de latencia. withToken() añade la cabecera Authorization: Bearer ... que espera Finexly.
Registro en el service provider
Vincula el servicio en app/Providers/AppServiceProvider.php para que la inyección de dependencias funcione:
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'],
);
});
}El alcance singleton es correcto: la clase no tiene estado por request, y un singleton permite reutilizar conexiones HTTP entre llamadas dentro de la misma request.
Cachear tipos con Laravel Cache (mejor con Redis)
La llamada a Cache::remember dentro de latest() hace más de lo que parece. Con el driver de fichero el comportamiento es correcto, pero el cache stampede al expirar puede pegarle a Finexly con una ráfaga. Con Redis ayudan dos flags:
// .env
CACHE_STORE=redis
REDIS_CLIENT=phpredisPara los pocos endpoints donde no toleras stampede —el checkout, por ejemplo— envuelve la llamada en 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 proceso refresca el upstream a la vez; los demás esperan hasta 3 segundos a que la caché se rellene. El patrón de stampede + reintento lo cubrimos a fondo en nuestra guía de caching y manejo de errores en APIs de divisas.
Persistir tipos con Eloquent
La caché es para lecturas calientes. La base de datos es para durabilidad: cuando la caché se enfría (un Redis reiniciado, un deploy que vacía el store), quieres los tipos más recientes disponibles sin un round trip al proveedor. Genera la migración:
php artisan make:model ExchangeRate -mEdítala:
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 precisión decimal(18,8) importa. Guardar tipos como floats trunca pips fraccionarios en pares con JPY y produce errores visibles de conciliación a escala. Ocho decimales cubren cualquier cruce publicado.
El modelo:
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);
}
}Añade un método al servicio que vuelque la respuesta a la tabla, útil para auditoría e histórico:
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 el clásico doble round trip de "insertar o actualizar": una sola sentencia, idempotente sobre la clave única, perfecto para el refresco diario de abajo.
Programar un refresco diario
El scheduler de Laravel es el sitio adecuado para el snapshot. Abre 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 ventana 16:30 CET captura el fixing de referencia del BCE de las 16:00 CET; quince minutos de margen cubren cualquier retraso del proveedor. onOneServer() es crítico si corres más de un worker: sin él, un deploy horizontal invocaría el snapshot N veces y duplicaría el consumo de cuota. No olvides el cron en el servidor:
* * * * * cd /var/www/fx-converter && php artisan schedule:run >> /dev/null 2>&1El endpoint del conversor
Cabléalo a una ruta y un controlador. 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');El controlador:
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 vista Blade mínima en 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'] }}
(tipo {{ $result['rate'] }}, a fecha {{ $result['as_of'] }})</p>
@endissetCuarenta líneas de Blade y un controlador: el conversor entero de cara al usuario. Si prefieres una versión hospedada y pulida, nuestro propio conversor de divisas en finexly.com está construido sobre los mismos endpoints.
Una regla de validación a medida para ISO 4217
Validar a mano con 'currencies' => 'in:USD,EUR,GBP,...' aguanta diez códigos y rompe a los 170. Genera una regla:
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.");
}
}
}¿Quieres validación más estricta? Cachea la lista viva de símbolos:
$symbols = Cache::remember('fx:symbols', 86400, function () use ($fx) {
return array_keys($fx->latest('USD'));
});Y comprueba in_array(strtoupper($value), $symbols, true) dentro de la regla. Tienes la referencia completa del estándar en nuestra guía de códigos ISO 4217.
Errores de API, reintentos y rate limits
El retry/timeout del servicio cubre los problemas transitorios. Para caídas sostenidas quieres una cadena de fallback: caché → base de datos → upstream. Reordena 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;
}
}Aunque Finexly devuelva 503 durante una hora, tu checkout sigue funcionando con el último snapshot de la base de datos. Acompáñalo de un plan de precios que te dé margen sobre el rate limit: ir al 95% de la cuota es pedir que el throttle salte a 429 en cuanto hay un pico.
Para aplicaciones donde los milisegundos importan —dashboards de trading, por ejemplo— el patrón de snapshot es lento. Pasa a un modelo streaming; comparamos los trade-offs en REST vs WebSocket para conversión de divisas.
Tests con Pest y Http::fake()
Los tests no deberían tocar nunca la API en vivo. Http::fake() lo deja trivial. 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);
});Lánzalos con php artisan test. Tres tests, cobertura completa del camino feliz, el de validación y el de fallback, y cero llamadas de red.
Checklist de producción antes de salir
Una lista corta de cosas que la gente olvida en el primer deploy:
- Encola el snapshot. Envuelve la llamada del scheduler en
dispatch(new RefreshRatesJob('USD'))->onQueue('fx')para que un upstream lento no bloquee el scheduler. - Monitoriza 4xx y 5xx por separado. Un 401 es que la clave rotó; un 429, que necesitas un plan más grande; un 503, que el upstream está caído. Tres alertas distintas.
- Fija un
User-Agent. El tráfico anónimo es lo primero que despriorizan los proveedores bajo carga. - No loguees la API key. Añade
FINEXLY_API_KEYa la lista de redacción deconfig/logging.php. - Pon un circuit breaker. Cinco fallos consecutivos en 60 segundos → para de llamar al upstream, sirve solo desde caché y avisa al de guardia.
- Snapshot también los fines de semana. La mayoría de proveedores congelan los tipos el fin de semana, pero tener filas del sábado y domingo simplifica el reporting de fin de mes.
Preguntas frecuentes
¿Para qué versión de Laravel está pensado este tutorial?
Laravel 11. El código funciona sin cambios en Laravel 10; la única diferencia es dónde se registran las tareas programadas (app/Console/Kernel.php en 10, routes/console.php en 11). Laravel 12 (publicado en marzo de 2025) también es totalmente compatible.
¿Puedo usarlo con Lumen?
La service class no tiene dependencias específicas y se porta directa. Tendrás que registrar el binding a mano porque Lumen no auto-descubre providers, y la API del scheduler difiere ligeramente: usa Lumen\Framework\Console\Scheduling\Schedule.
¿Con qué frecuencia debo refrescar los tipos? Para facturación B2B, una vez al día tras el fixing del BCE de las 16:00 CET es el estándar. Para checkout B2C, cada 5-15 minutos durante horario de mercado. Para trading o dashboards de tesorería, WebSocket en tiempo real: el polling REST siempre llegará tarde. Nuestra API gratis de tipos de cambio cubre los trade-offs.
¿Qué pasa si la API upstream cae? Con la cadena de fallback que hemos montado, la caché sirve los últimos tipos hasta que expira el TTL, después la base de datos sirve el snapshot más reciente hasta 7 días, y solo entonces falla la request. En la práctica, tu checkout sigue funcionando durante cualquier caída de un mismo día.
¿Necesito un plan de pago para seguir el tutorial? No. El plan gratis de Finexly —1.000 peticiones al mes, sin tarjeta— alcanza para todos los pasos. Con TTL de 15 minutos soporta unas 30.000 visitas diarias antes de tocar la cuota.
¿Listo para enchufar tipos de cambio en vivo a tu app Laravel? Consigue tu clave gratis de Finexly: 1.000 peticiones al mes, sin tarjeta, 30 segundos. Si te quedas corto, nuestros planes de precios empiezan en 9 $/mes y escalan hasta enterprise. O compara APIs de divisas lado a lado antes de decidir.
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 →