Большинство Laravel-приложений, которые работают с деньгами, рано или поздно нуждаются в курсах валют — будь то выставление счёта в трёх валютах, конвертация выплат Stripe в свою базовую валюту или показ локализованных цен в чекауте. Самый простой путь — подключить API курсов валют для Laravel и считать FX решённой задачей. Не такой простой — сделать это правильно: агрессивно кэшировать, чтобы не сжечь квоту; персистить курсы, чтобы 5xx с upstream не положил чекаут; планировать обновления вокруг фиксинга ЕЦБ в 16:00 CET; и писать тесты, которые не ходят в сеть.
Это руководство проходит всё это. Мы соберём небольшой конвертер на Laravel 11, API Finexly, Eloquent для персистентности, фасаде Cache для горячих чтений, scheduler для обновления, кастомном правиле валидации для кодов ISO 4217 и Pest с Http::fake() для тестов. В итоге получите архитектуру на service classes, которую можно пристроить к любому Laravel-приложению — SaaS-биллинг, e-commerce, бухгалтерия, везде, где деньги пересекают границу.
Почему специализированный API курсов лучше захардкоженных значений
Захардкоженные курсы в config-файле — первый неверный ответ. Дёргать upstream на каждый запрос — второй. Правильно используемый API курсов даёт четыре вещи, недоступных фиксированным значениям:
- Свежесть по требованию. Курсы постоянно меняются в рабочие часы рынка. Если вы списываете деньги с клиентов, даже сутки отставания на волатильных парах вроде USD/JPY или EUR/TRY дают 1-2% — этого хватит, чтобы стереть маржу SaaS-плана.
- Широкое покрытие. Finexly покрывает 170+ валют, включая развивающиеся рынки и эталонные CBDC. ECB-фид, идущий в комплекте с Laravel Swap, покрывает около 32 мажорных. Стоит появиться одному клиенту, платящему в аргентинских песо или турецких лирах — и разница ощутима.
- Единый контракт. Одна форма JSON в
latest,historicalиconvert, а не три разных upstream'а, склеенных скотчем. - Предсказуемые лимиты. Документированная квота, с которой можно работать, вместо «ЕЦБ заблокировал нашу IP за слишком частый polling».
Сравнение опций мы подробно разобрали в сравнении бесплатных и платных API курсов на 2026 и в ExchangeRate-API vs CurrencyLayer vs Finexly.
Сетап проекта: Laravel 11 + Composer
Туториал предполагает свежую установку Laravel 11 (Laravel 10 ведёт себя так же; единственное отличие — bootstrap/app.php, который трогаем на шаге 5).
laravel new fx-converter
cd fx-converter
php artisan migrateПоложите ключ Finexly в .env:
FINEXLY_API_KEY=ваш_ключ_здесь
FINEXLY_BASE_URL=https://finexly.com/api/v1
FX_CACHE_TTL=900
FX_DEFAULT_BASE=USD15 минут (900 секунд) — разумный TTL для B2B-биллинга. Потребительский чекаут обычно хочет 60-300 секунд; treasury-дашборд можно растянуть до 3 600. Выбирайте по риску, который вы готовы нести по неблагоприятным движениям курса.
Соответствующий блок в 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'),
],Ключа ещё нет? Зарегистрируйтесь в Finexly — бесплатный тариф даёт 1 000 запросов в месяц, на side-проект или маленький SaaS более чем достаточно.
Класс ExchangeRateService
Правило, которому следуем по всему туториалу: ни один контроллер, job или Blade-вью не общается с Finexly напрямую. Всё проходит через одну service class — это делает моки тривиальными в тестах и оставляет одно место для замены провайдера или добавления fallback-логики.
Создайте 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'];
}
}Несколько моментов. retry(2, 250) — это два автоматических ретрая с backoff 250 мс, что снимает большинство транзиентных 502/503 без какого-либо retry-кода с вашей стороны. timeout(8) консервативен: меньше 5 секунд тесно, если upstream на другом континенте, а больше 10 — нестабильный upstream разнесёт ваш бюджет latency. withToken() ставит заголовок Authorization: Bearer ..., который ждёт Finexly.
Регистрация в service-провайдере
Свяжите сервис в app/Providers/AppServiceProvider.php, чтобы DI работало само:
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 тут уместен — в классе нет per-request состояния, и singleton позволяет нижележащему HTTP-клиенту переиспользовать соединения внутри одного запроса.
Кэшируем курсы через Laravel Cache (рекомендуется Redis)
Вызов Cache::remember внутри latest() делает больше, чем кажется. С file-драйвером поведение корректно, но cache stampede на момент истечения может ударить в Finexly залпом. С Redis помогают два флага:
// .env
CACHE_STORE=redis
REDIS_CLIENT=phpredisДля тех немногих эндпоинтов, где stampede недопустим (чекаут, например), оборачивайте вызов в 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));
}Только один процесс перезапрашивает upstream одновременно; остальные ждут до 3 секунд, пока кэш не наполнится. Полный шаблон stampede + retry разобран в нашем гайде по кэшированию и обработке ошибок в API курсов.
Персистенция через Eloquent
Кэш — для горячих чтений. БД — для долговечности: когда кэш остыл (рестарт Redis, деплой, опустошающий store), хочется получить последние курсы без обращения к upstream. Сгенерируйте миграцию:
php artisan make:model ExchangeRate -mСодержимое:
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']);
});Точность decimal(18,8) важна. Хранение курсов как float обрезает дробные пипсы на парах с JPY и в масштабе даёт видимые ошибки сверки. Восемь знаков после запятой покрывают любой публикуемый кросс-курс.
Модель:
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);
}
}Добавьте в сервис метод, делающий снапшот ответа в таблицу — пригодится и для аудита, и для исторического анализа:
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 исключает классический двойной round trip «вставить или обновить» — один запрос, идемпотентный по уникальному ключу, идеально для ежедневного обновления ниже.
Планируем ежедневное обновление
Scheduler Laravel — правильное место для снапшота. Откройте routes/console.php (Laravel 11) или 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();Окно 16:30 CET ловит референсный фиксинг ЕЦБ в 16:00 CET — пятнадцать минут запаса покрывают любую задержку провайдера. onOneServer() критичен, если у вас больше одного worker; без него горизонтальный деплой запустит снапшот N раз и удвоит расход квоты. Не забудьте cron на сервере:
* * * * * cd /var/www/fx-converter && php artisan schedule:run >> /dev/null 2>&1Эндпоинт конвертера
Подключаем роуты и контроллер. 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');Контроллер:
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(),
],
]);
}
}Минимальная Blade-вью 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">Конвертировать</button>
</form>
@isset($result)
<p>{{ $result['amount'] }} {{ $result['from'] }} =
{{ $result['converted'] }} {{ $result['to'] }}
(курс {{ $result['rate'] }}, на {{ $result['as_of'] }})</p>
@endissetСорок строк Blade и один контроллер — весь пользовательский конвертер. Если хотите готовую полированную хостовую версию — наш конвертер валют на finexly.com построен на тех же эндпоинтах.
Кастомное правило валидации для ISO 4217
Валидация вручную через 'currencies' => 'in:USD,EUR,GBP,...' живёт до десятка кодов и ломается на 170. Сгенерируйте нормальное правило:
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.");
}
}
}Хотите строже? Закэшируйте живой список символов:
$symbols = Cache::remember('fx:symbols', 86400, function () use ($fx) {
return array_keys($fx->latest('USD'));
});И проверяйте in_array(strtoupper($value), $symbols, true) внутри правила. Полный справочник стандарта в нашем гайде по кодам ISO 4217.
Ошибки API, ретраи и rate-лимиты
Retry/timeout в сервисе закрывает транзиентные сбои. Для долгих сбоев нужна цепочка fallback: cache → БД → upstream. Перепишите 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;
}
}Даже если Finexly возвращает 503 в течение часа, чекаут продолжает работать на последнем снапшоте из БД. Сочетайте это с ценовым тарифом, который оставляет запас по rate limit — работа на 95% квоты — это приглашение для throttle превратиться в 429 при первом всплеске.
Для приложений, где важны миллисекунды (трейдинг-дашборды, например), снапшот-модель медленна. Уходите в стриминг; компромиссы мы сравнили в REST vs WebSocket для конвертации валют.
Тесты на Pest и Http::fake()
Тесты не должны ходить в живой API. Http::fake() решает это тривиально. Создайте 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);
});Запустите php artisan test. Три теста — happy path, путь валидации и fallback — и ноль сетевых вызовов.
Production-чек-лист перед деплоем
Короткий список того, о чём забывают на первом релизе:
- Поставьте снапшот в очередь. Оберните вызов scheduler в
dispatch(new RefreshRatesJob('USD'))->onQueue('fx'), чтобы медленный upstream не блокировал scheduler. - Мониторьте 4xx и 5xx раздельно. 401 — ключ ротировался; 429 — нужен план побольше; 503 — лежит upstream. Три разных алерта.
- Зафиксируйте
User-Agent. Анонимный трафик первым понижается в приоритете под нагрузкой. - Не логируйте API-ключ. Добавьте
FINEXLY_API_KEYв список redaction вconfig/logging.php. - Поставьте circuit breaker. Пять последовательных ошибок за 60 секунд → перестать вызывать upstream, отдавать только из кэша, оповестить дежурного.
- Снимайте снапшот и в выходные. Многие провайдеры замораживают курсы на выходные, но строки за субботу/воскресенье в БД упрощают отчётность по концу месяца.
Часто задаваемые вопросы
На какую версию Laravel рассчитан туториал?
Laravel 11. Код работает без изменений на Laravel 10; разница лишь в месте регистрации запланированных задач (app/Console/Kernel.php в 10, routes/console.php в 11). Laravel 12 (выпущен в марте 2025) тоже полностью совместим.
Можно ли использовать с Lumen?
service class независим и переносится один в один. Binding придётся регистрировать вручную, поскольку Lumen не находит провайдеры автоматически, а API scheduler чуть отличается — используйте Lumen\Framework\Console\Scheduling\Schedule.
Как часто обновлять курсы? Для B2B-биллинга стандарт — раз в день после фиксинга ЕЦБ в 16:00 CET. Для потребительского чекаута — каждые 5-15 минут в часы рынка. Для трейдинга или treasury-дашборда — реалтайм по WebSocket; REST-polling всегда отстанет. Компромиссы — в гайде по бесплатному API курсов валют.
Что если upstream API недоступен? С построенной цепочкой fallback кэш отдаёт последние курсы до истечения TTL, потом БД отдаёт самый свежий снапшот в пределах 7 дней, а только потом запрос падает. На практике чекаут переживает любой сбой одного дня.
Нужен ли платный тариф, чтобы пройти туториал? Нет. Бесплатного уровня Finexly — 1 000 запросов в месяц, без карты — хватит на каждый шаг. С 15-минутным TTL это поддерживает около 30 000 просмотров страниц в день до удара в квоту.
Готовы воткнуть живые курсы валют в ваше Laravel-приложение? Получите бесплатный API-ключ Finexly — 1 000 запросов в месяц, без карты, 30 секунд. Если выйдете за бесплатный лимит, наши тарифы начинаются с $9/мес и масштабируются до enterprise. Или сравните API курсов валют бок о бок перед выбором.
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 →