Voltar ao Blog

Como criar um conversor de moedas em Laravel: tutorial completo de API de câmbio (2026)

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

A maioria das aplicações Laravel que mexem com dinheiro acaba precisando de taxas de câmbio — seja faturando em três moedas, convertendo repasses do Stripe para a sua moeda base, ou exibindo preços localizados no checkout. O caminho mais simples é plugar uma API de câmbio para Laravel e tratar FX como problema resolvido. O caminho difícil é fazer isso direito: cachear com agressividade para não estourar a quota, persistir taxas para que um 5xx do upstream não derrube o checkout, agendar o refresh em torno do fixing do Banco Central Europeu às 16:00 CET, e escrever testes que não chamem a rede.

Este guia cobre tudo isso. Vamos construir um conversor pequeno usando Laravel 11, a API da Finexly, Eloquent para persistência, a facade Cache para leituras quentes, o scheduler para refresh, uma regra de validação custom para códigos ISO 4217 e Pest com Http::fake() nos testes. No fim você terá uma arquitetura baseada em service class que dá pra colar em qualquer app Laravel — billing SaaS, e-commerce, contabilidade, qualquer lugar onde o dinheiro cruze fronteira.

Por que uma API de câmbio dedicada vence taxas hardcoded

Cravar taxas num arquivo de config é a primeira resposta errada. Chamar o upstream a cada request é a segunda. Uma API de câmbio bem usada te dá quatro coisas que valores fixos não dão:

  • Frescor sob demanda. As taxas mudam o tempo todo durante o pregão. Se você cobra clientes, mesmo uma taxa 24h atrasada pode mover 1-2% em pares voláteis como USD/JPY ou EUR/TRY — o suficiente pra apagar a margem de um plano SaaS.
  • Cobertura ampla. A Finexly cobre mais de 170 moedas, incluindo emergentes e referências CBDC. O feed do BCE que vem com o Laravel Swap cobre cerca de 32 majors. Se você tem um único cliente pagando em peso argentino ou lira turca, essa diferença pesa.
  • Um único contrato. Um mesmo formato JSON em latest, historical e convert, em vez de três upstreams diferentes amarrados na fita.
  • Rate limit previsível. Uma quota documentada que dá pra raciocinar, em vez de "o BCE bloqueou nosso IP porque demos polling demais".

Pra comparar opções, cobrimos isso a fundo em free vs pago: comparação de APIs de câmbio para 2026 e em ExchangeRate-API vs CurrencyLayer vs Finexly.

Setup do projeto: Laravel 11 + Composer

Este tutorial assume um Laravel 11 limpo (Laravel 10 funciona igual; a única diferença é o bootstrap/app.php que tocamos no passo 5):

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

Adicione sua API key da Finexly no .env:

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

15 minutos (900 segundos) é um TTL de cache razoável pra billing B2B. Checkout consumer costuma querer 60-300 segundos; um dashboard de tesouraria pode esticar pra 3.600. Escolha conforme o risco que você aceita em movimentos adversos.

Adicione o bloco em 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'),
],

Sem chave ainda? Cadastre-se na Finexly — o plano free libera 1.000 requests por mês, suficiente pra um side project ou SaaS pequeno.

A classe ExchangeRateService

A regra que vamos seguir o tempo todo: nenhum controller, job ou view Blade fala com a Finexly diretamente. Tudo passa por uma única service class, o que torna mocks triviais nos testes e deixa um único ponto pra trocar provider ou adicionar fallback.

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

Algumas coisas merecem destaque. retry(2, 250) te dá dois retries automáticos com 250 ms de backoff, o que cobre a grande maioria dos 502/503 transientes sem você escrever lógica de retry. timeout(8) é conservador — abaixo de 5 segundos é apertado se você cruza continentes até o upstream, e acima de 10 faz com que um upstream instável arrebente seu orçamento de latência. withToken() adiciona o header Authorization: Bearer ... que a Finexly espera.

Registro no service provider

Vincule o serviço em app/Providers/AppServiceProvider.php pra DI funcionar:

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 é o escopo certo — não há estado por request na classe, e singleton permite que o cliente HTTP reaproveite conexões dentro da mesma request.

Cacheando taxas com Laravel Cache (Redis recomendado)

A chamada a Cache::remember dentro do latest() faz mais do que parece. Com o driver de file você tem comportamento correto, mas o stampede no momento da expiração pode bater na Finexly em rajada. Com Redis, dois flags ajudam:

// .env
CACHE_STORE=redis
REDIS_CLIENT=phpredis

Pros poucos endpoints onde você não tolera stampede — checkout, por exemplo — embrulhe em 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));
}

Apenas um processo refaz o upstream por vez; os outros esperam até 3 segundos pelo cache. O padrão completo de stampede + retry tá em nosso guia de cache e tratamento de erros em APIs de câmbio.

Persistindo com Eloquent

Cache resolve leitura quente. Banco resolve durabilidade — quando o cache esfria (Redis reiniciado, deploy que limpa o store), você quer as taxas mais recentes disponíveis sem round trip ao upstream. Gere a migration:

php artisan make:model ExchangeRate -m

Edite:

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

A precisão decimal(18,8) importa. Salvar taxa como float trunca pips fracionários em pares com JPY e gera erros visíveis de conciliação em escala. Oito casas cobrem qualquer cross publicado.

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

Adicione um método ao service que tira snapshot da resposta na tabela — útil pra auditoria e análise histórica:

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 o "select ou insert, senão update" típico — uma única statement, idempotente sobre a chave única, perfeita pro refresh diário.

Agendando refresh diário

O scheduler do Laravel é o lugar certo. Abra 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();

A janela 16:30 CET pega o fixing de referência do BCE das 16:00 CET — quinze minutos de folga absorvem qualquer atraso do provider. onOneServer() é crítico se você roda mais de um worker; sem isso, um deploy horizontal invocaria o snapshot N vezes e duplicaria o gasto da quota. Não esqueça do cron no servidor:

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

O endpoint do conversor

Cabeie a rota e o 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');

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

Uma view Blade mínima em 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">Converter</button>
</form>

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

Quarenta linhas de Blade e um controller — o conversor inteiro. Se preferir uma versão hospedada e bonita, nosso conversor de moedas na finexly.com roda nos mesmos endpoints.

Regra custom para ISO 4217

Validar na mão com 'currencies' => 'in:USD,EUR,GBP,...' aguenta dez códigos e quebra em 170. Gere uma regra:

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.");
        }
    }
}

Quer rigor maior? Cacheie a lista viva de symbols:

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

E cheque in_array(strtoupper($value), $symbols, true) dentro da regra. A referência completa do padrão tá no nosso guia de códigos ISO 4217.

Erros de API, retries e rate limits

O retry/timeout no service resolve oscilações transientes. Pra outage prolongado, você quer uma cadeia de fallback: cache → banco → upstream. Reorganize 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;
    }
}

Mesmo com a Finexly retornando 503 por uma hora, seu checkout segue funcionando com o snapshot mais recente. Combine com um plano de preços que te dê folga sobre o rate limit — operar a 95% da quota é pedir pro throttle virar 429 no primeiro pico.

Pra apps onde milissegundos importam — dashboards de trading, por exemplo — snapshot é lento. Vá pra streaming; comparamos os trade-offs em REST vs WebSocket pra conversão de moedas.

Testes com Pest e Http::fake()

Testes nunca devem chamar a API real. Http::fake() resolve. Crie 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);
});

Rode com php artisan test. Três testes, cobertura completa do happy path, do path de validação e do fallback — e zero chamada de rede.

Checklist de produção antes de subir

Coisas que costumam ser esquecidas no primeiro deploy:

  • Enfileire o snapshot. Embrulhe a chamada do scheduler em dispatch(new RefreshRatesJob('USD'))->onQueue('fx') pra que um upstream lento não trave o scheduler.
  • Monitore 4xx e 5xx separadamente. 401 = chave rotacionada; 429 = plano pequeno demais; 503 = upstream caído. Três alertas distintos.
  • Fixe um User-Agent. Tráfego anônimo é o primeiro a ser despriorizado por providers sob carga.
  • Não logue a API key. Adicione FINEXLY_API_KEY à lista de redaction do config/logging.php.
  • Implemente circuit breaker. Cinco falhas seguidas em 60 segundos → para de chamar upstream, serve só do cache, alerta o on-call.
  • Faça snapshot nos finais de semana também. A maioria dos providers congela taxas no fim de semana, mas ter sábado e domingo no banco simplifica o relatório de fechamento mensal.

Perguntas frequentes

Pra qual versão do Laravel é esse tutorial? Laravel 11. O código roda sem mudança no Laravel 10; a única diferença é onde se registram tarefas agendadas (app/Console/Kernel.php no 10, routes/console.php no 11). Laravel 12 (lançado em março de 2025) também é totalmente compatível.

Dá pra usar com Lumen? A service class não tem dependência específica e porta direta. Você precisa registrar o binding manualmente porque Lumen não auto-descobre providers, e a API do scheduler é um pouco diferente: use Lumen\Framework\Console\Scheduling\Schedule.

Com que frequência atualizar as taxas? Pra billing B2B, uma vez por dia depois do fixing do BCE 16:00 CET é o padrão. Pra checkout B2C, 5-15 minutos durante pregão. Pra trading ou tesouraria, WebSocket em tempo real — polling REST sempre vai estar atrasado. Trade-offs no guia da API gratuita de câmbio.

E se a API upstream cair? Com a cadeia de fallback construída, o cache serve as últimas taxas até o TTL expirar, depois o banco serve o snapshot mais recente até 7 dias, e só aí a request falha. Na prática, seu checkout sobrevive a qualquer outage do mesmo dia.

Preciso de plano pago pra seguir o tutorial? Não. O tier free da Finexly — 1.000 requests por mês, sem cartão — basta pra todos os passos. Com TTL de 15 minutos isso suporta cerca de 30.000 page views por dia antes de bater na quota.


Pronto pra plugar taxas em tempo real no seu app Laravel? Pegue sua API key gratuita da Finexly — 1.000 requests/mês, sem cartão, leva 30 segundos. Se ultrapassar o free, nossos planos de preços começam em US$ 9/mês e escalam até enterprise. Ou compare APIs de câmbio lado a lado antes de decidir.

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 →

Compartilhar este artigo