ブログに戻る

Laravelで通貨コンバーターを作る:為替レートAPI完全チュートリアル(2026年版)

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

お金を扱うLaravelアプリは、遅かれ早かれ為替レートが必要になります。3通貨で請求書を出す、Stripeの送金額を自国通貨に戻す、チェックアウトでローカライズ価格を出す——どれも同じです。最も簡単な方法は Laravel向け為替レートAPI を差し込んで、FXを「解決済みの問題」として扱うこと。難しいのは正しくやることです。割り当てを焼き切らないよう積極的にキャッシュし、上流の5xxでチェックアウトが落ちないようレートを永続化し、欧州中央銀行の16:00 CETフィキシングに合わせて更新をスケジュールし、ネットワークを叩かないテストを書く——これらを揃える必要があります。

本記事ではこれを一通り扱います。Laravel 11Finexly 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通貨ほど。アルゼンチン・ペソやトルコ・リラで支払う顧客が一人でもいれば、その差は効きます。
  • 単一のコントラクト。 latesthistoricalconvertが同じ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=USD

15分(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>
@endisset

40行のBladeとコントローラー1つで、ユーザー側のコンバーターが完成です。完成度の高いホスト版が必要なら、finexly.comの通貨コンバーターが同じエンドポイントの上に作られています。

ISO 4217用のカスタムバリデーションルール

'currencies' => 'in:USD,EUR,GBP,...' のような手書きは10コードまでで詰みます。きちんとしたルールを生成:

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

より厳格にしたい?シンボル一覧をキャッシュして使います:

$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を比較 も。

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 →

この記事を共有する