お金を扱うLaravelアプリは、遅かれ早かれ為替レートが必要になります。3通貨で請求書を出す、Stripeの送金額を自国通貨に戻す、チェックアウトでローカライズ価格を出す——どれも同じです。最も簡単な方法は Laravel向け為替レートAPI を差し込んで、FXを「解決済みの問題」として扱うこと。難しいのは正しくやることです。割り当てを焼き切らないよう積極的にキャッシュし、上流の5xxでチェックアウトが落ちないようレートを永続化し、欧州中央銀行の16:00 CETフィキシングに合わせて更新をスケジュールし、ネットワークを叩かないテストを書く——これらを揃える必要があります。
本記事ではこれを一通り扱います。Laravel 11、Finexly API、永続化用のEloquent、ホット読み込み用のCacheファサード、更新用のscheduler、ISO 4217用のカスタムバリデーションルール、Http::fake()を使ったPestテストで、小さな通貨コンバーターを作ります。読み終わると、SaaS課金、Eコマース、会計など、お金が国境を越えるどんなLaravelアプリにも投入できるservice classアーキテクチャが手に入ります。
ハードコードより専用為替レートAPIが優れる理由
レートをconfigに直書きするのは最初の誤答。リクエストごとに上流APIを叩くのが二つ目の誤答。きちんと使った専用APIは、固定値では得られない4つを与えてくれます。
- オンデマンドの鮮度。 レートは平日の市場時間中、絶え間なく動きます。顧客から課金している場合、24時間古いレートでもUSD/JPYやEUR/TRYのようなボラタイルなペアでは1〜2%動くこと——SaaSプランの利益が消えるレベルです。
- 広いカバレッジ。 Finexlyはエマージング通貨やCBDC参考レートを含む170+通貨を扱います。Laravel SwapにバンドルされるECBフィードは主要32通貨ほど。アルゼンチン・ペソやトルコ・リラで支払う顧客が一人でもいれば、その差は効きます。
- 単一のコントラクト。
latest、historical、convertが同じJSON形状。3つの上流をテープで貼り合わせる必要なし。 - 予測可能なレートリミット。 ドキュメント化されたクオータで設計できる。「ECBにポーリング過多でIPブロックされた」のようなことが起きません。
選定の比較は2026年版・無料 vs 有料 通貨APIの比較 と ExchangeRate-API vs CurrencyLayer vs Finexly で詳述しています。
プロジェクトのセットアップ:Laravel 11 + Composer
このチュートリアルはLaravel 11の新規インストールを前提とします(Laravel 10でも同じ。違いはステップ5で触る bootstrap/app.php のみ)。
laravel new fx-converter
cd fx-converter
php artisan migrate.envにFinexlyのAPIキーを書きます。
FINEXLY_API_KEY=your_key_here
FINEXLY_BASE_URL=https://finexly.com/api/v1
FX_CACHE_TTL=900
FX_DEFAULT_BASE=USD15分(900秒)はB2B課金には妥当な既定値です。コンシューマー向けチェックアウトは60〜300秒、トレジャリーダッシュボードは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リクエストで、副業プロジェクトや小さなSaaSなら十分です。
ExchangeRateServiceクラスを作る
通底するルール:コントローラー、ジョブ、Bladeビューは絶対にFinexlyを直接叩かない。 すべて1つの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) は250msバックオフで2回まで自動リトライします。これだけで、リトライコードを書かずに大半の一時的な502/503を吸収できます。timeout(8)は保守的——5秒以下は大陸越えだとキツく、10秒超えは上流の不安定さがリクエスト遅延予算を破壊します。withToken()はFinexlyが期待する Authorization: Bearer ... ヘッダーを付与します。
サービスプロバイダの登録
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なら同一リクエスト内でHTTP接続を再利用できるため、singletonが正解です。
Laravel Cacheでキャッシュ(Redis推奨)
latest()内の Cache::remember は、見た目以上の働きをします。fileドライバでも動作は正しいですが、TTL切れ時のキャッシュスタンピードでFinexlyにバーストが飛びます。Redisなら2つのフラグが効きます:
// .env
CACHE_STORE=redis
REDIS_CLIENT=phpredisスタンピードを許容できないエンドポイント(チェックアウトなど)では 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));
}上流再取得は1プロセスのみ、他は最大3秒キャッシュ充填を待ちます。スタンピードとリトライのパターン全体は通貨APIキャッシュ&エラーハンドリングのベストプラクティス に詳しいです。
Eloquentで永続化
キャッシュはホット読み込み用。データベースは耐久性——キャッシュが冷えたとき(Redis再起動、ストアをクリアするデプロイ)にも、上流ラウンドトリップなしで直近のレートを使いたい。マイグレーションを生成:
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ペアで小数pipsが切り捨てられ、規模が出ると目に見える照合誤差を生みます。8桁は公開されているクロスをすべて吸収できます。
モデル:
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は典型的な「挿入か更新か」の二重往復を回避します。一文で済み、ユニークキー上でべき等。日次リフレッシュにぴったりです。
日次リフレッシュをスケジュール
Laravelのschedulerが置き場所として正解。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のECB参照フィキシングを捕捉します——15分の余裕で上流側遅延を吸収できます。ワーカーが複数なら onOneServer() は必須。これがないと水平デプロイでスナップショットが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>
@endisset40行のBladeとコントローラー1つで、ユーザー側のコンバーターが完成です。完成度の高いホスト版が必要なら、finexly.comの通貨コンバーターが同じエンドポイントの上に作られています。
ISO 4217用のカスタムバリデーションルール
'currencies' => 'in:USD,EUR,GBP,...' のような手書きは10コードまでで詰みます。きちんとしたルールを生成:
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エラー、リトライ、レートリミット
サービスのretry/timeoutは一時的なブリップに対応。長期障害にはfallbackチェーン:cache → DB → 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が1時間503を返してもチェックアウトはDBの最新スナップショットで稼働を続けます。レートリミットの余裕を取れる料金プランと組み合わせを——95%稼働は、トラフィックスパイクで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で実行。3テストでhappy path、バリデーション、fallbackをカバー。ネットワーク呼び出しゼロ。
リリース前の本番チェックリスト
初回デプロイで忘れがちなものを短く:
- スナップショットをキューに。 schedulerの呼び出しを
dispatch(new RefreshRatesJob('USD'))->onQueue('fx')で包んで、上流の遅延でschedulerを止めない。 - 4xxと5xxを分けて監視。 401はキー回転、429はプラン拡大、503は上流ダウン。アラートを3つ用意。
User-Agentを固定。 多くのプロバイダーは負荷時に匿名トラフィックを真っ先に降格させます。- APIキーをログに残さない。
config/logging.phpの伏字対象にFINEXLY_API_KEYを追加。 - サーキットブレーカーを置く。 60秒以内に連続5回失敗 → 上流呼び出し停止、キャッシュのみ提供、オンコール通知。
- 週末もスナップショット。 多くのプロバイダーは週末レートを凍結しますが、土日の行があると月末レポートが楽になります。
よくある質問
この記事はどのLaravelバージョン向け?
Laravel 11です。Laravel 10でも同じコードがそのまま動きます。違いはタスクの登録場所だけ(10は app/Console/Kernel.php、11は routes/console.php)。Laravel 12(2025年3月リリース)も完全互換です。
Lumenでも使える?
service classはLaravel依存がないため移植は容易。Lumenはプロバイダの自動検出をしないのでバインディングを手動登録、schedulerは少し違うので Lumen\Framework\Console\Scheduling\Schedule を使います。
レート更新の頻度は? B2B課金なら16:00 CETのECBフィキシング後に1日1回が業界標準。コンシューマーチェックアウトは市場時間中5〜15分。トレーディングや財務ダッシュボードはリアルタイムWebSocket——RESTポーリングは常に遅れます。詳細は無料為替レートAPIガイド で。
上流APIが落ちたら? 構築したfallbackチェーンにより、TTL期限までキャッシュが直近レートを返し、その後DBが7日以内の最新スナップショットを返し、それでも失敗したらリクエストエラー。実運用では同日障害でもチェックアウトは稼働を続けます。
有料プランは必要? 不要です。Finexlyの無料枠(月1,000リクエスト、クレジットカード不要)で全工程を辿れます。15分TTLなら、クオータに達する前に1日約30,000ページビューを支えます。
Laravelアプリにライブの為替レートを差し込む準備はできましたか?Finexlyの無料APIキーを取得——月1,000リクエスト、カード不要、30秒。無料枠を超えても料金プランは月9ドルから。決める前に通貨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 →