返回博客

如何在 Laravel 中构建货币转换器:汇率 API 完整教程(2026)

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

只要 Laravel 应用涉及金钱,迟早都会需要汇率——无论是用三种货币开发票、把 Stripe 收入折回到本币,还是在结账页显示本地化价格。最简单的做法就是接入一个 Laravel 汇率 API,把 FX 当成已经解决的问题。难点在于做对:缓存要够激进以免烧光配额;要把汇率持久化,让上游 5xx 不至于让结账崩溃;刷新要对齐欧洲央行 16:00 CET 定盘;测试不能打真实网络。

本文把这些都过一遍。我们将基于 Laravel 11Finexly API、Eloquent 持久化、Cache facade 热读、scheduler 定时刷新、ISO 4217 自定义验证规则,以及配合 Http::fake() 的 Pest 测试来构建一个小型货币转换器。读完后你会得到一套可以直接套用到任何 Laravel 应用的 service class 架构——SaaS 计费、电商、会计,凡是钱跨境的地方都适用。

为什么专用的汇率 API 比硬编码更好

把汇率写死在 config 文件里是错误答案的第一个版本。每次请求都打上游 API 是错误答案的第二个版本。一个用得好的专用货币 API,会带来硬编码做不到的四件事:

  • 按需保鲜。 工作日盘中汇率持续变化。如果你在收用户钱,即便 24 小时未更新的汇率,在 USD/JPY 或 EUR/TRY 这类高波动盘上也可能跑出 1-2%——一个 SaaS 套餐的毛利就没了。
  • 币种覆盖广。 Finexly 覆盖 170+ 货币,包括新兴市场币种与 CBDC 参考价。Laravel Swap 默认走的欧洲央行 feed 大约只有 32 种主流币。哪怕只有一个客户付阿根廷比索或土耳其里拉,差距就显出来了。
  • 统一契约。 latesthistoricalconvert 三个端点同一种 JSON 形态,而不是把三家上游用胶带粘在一起。
  • 可预测的速率限制。 一份你能算清楚的配额文档,而不是"央行因为你 polling 太快把 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

把 Finexly 的 API key 写进 .env

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'),
],

还没有 key?免费注册 Finexly——免费版每月 1,000 次请求,对副业项目或小型 SaaS 完全够用。

构建 ExchangeRateService 类

通篇我们都坚守一条规则:不让任何 controller、job 或 Blade 视图直接调用 Finexly。 所有调用都经过一个 service class,让测试 mock 极其方便,也只在一个地方就能更换上游或加 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) 表示自动重试两次、每次 250 ms 退避,能消化绝大多数瞬时 502/503,无需自己写重试逻辑。timeout(8) 是保守值——低于 5 秒在跨大洲调用时太紧,高于 10 秒一旦上游不稳就会拖垮你的请求延迟预算。withToken() 会加上 Finexly 需要的 Authorization: Bearer ... 头。

服务提供者注册

app/Providers/AppServiceProvider.php 里把服务绑定一下,依赖注入就能直接工作:

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)

latest() 中的 Cache::remember 干的事比看起来多。用文件 cache driver 行为正确,但 TTL 到期时的 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));
}

只有一个进程能去拉上游;其它最多等 3 秒就能从 cache 读到。stampede 与重试的整套模式我们在 货币 API 缓存与错误处理指南 里讲得更深。

用 Eloquent 持久化汇率

Cache 解决热读。数据库提供持久性——当 cache 冷下来(Redis 重启、deploy 清空 store),你希望最新汇率仍然可用,而不必再走一次上游。生成 migration:

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

给 service 加一个把响应快照入库的方法,对审计与历史分析都有用:

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 的欧洲央行参考定盘——15 分钟的余量能消化任何上游延迟。如果你跑了多个 worker,onOneServer() 必不可少;不然横向扩容时 snapshot 会被执行 N 次,把配额翻倍消耗掉。别忘了在服务器加 cron:

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

货币转换端点

接路由和 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');

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

最小的 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">Convert</button>
</form>

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

40 行 Blade 加一个 controller,一个完整的用户侧转换器。如果你更想直接用现成版本,finexly.com 上的 货币转换器 就建在同一套端点上。

ISO 4217 自定义验证规则

手写 'currencies' => 'in:USD,EUR,GBP,...' 在十个码以下还行,到 170 个就崩了。生成正经规则:

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 列表缓存起来:

$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 limit

service 内的 retry/timeout 解决瞬时抖动。要应对持续故障,需要一条fallback 链:cache → 数据库 → 上游。把 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 一小时,结账仍能用数据库里的最近快照。再配一个有冗余的价格档位——长期跑在 95% 配额上,是在邀请 throttle 在流量一冲就翻成 429。

对毫秒级敏感的应用——比如交易仪表盘——快照模式太慢。该走 streaming,权衡见 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 路径,零网络调用。

上线前的生产清单

第一次上线常被忘掉的几件事:

  • 把 snapshot 入队列。 把 schedule 调用包进 dispatch(new RefreshRatesJob('USD'))->onQueue('fx'),避免上游慢拖住 scheduler。
  • 4xx 与 5xx 分开监控。 401 是 key 轮换;429 是该升级套餐了;503 是上游挂了。三种告警分别对待。
  • 固定一个 User-Agent 大多数提供方在过载时会优先压制匿名流量。
  • 不要打日志记录 API key。FINEXLY_API_KEY 加进 config/logging.php 的脱敏列表。
  • 加熔断器。 60 秒内连续 5 次失败 → 暂停调上游、只走 cache、通知值班。
  • 周末也要 snapshot。 多数提供方周末冻结汇率,但库里有周六周日的行能让月末报表轻松不少。

常见问题

这个教程针对哪个 Laravel 版本? Laravel 11。代码在 Laravel 10 上可以原样运行,唯一区别是计划任务注册的位置(10 在 app/Console/Kernel.php,11 在 routes/console.php)。Laravel 12(2025 年 3 月发布)也完全兼容。

能用在 Lumen 上吗? service class 不依赖 Laravel 特有功能,可以直接搬。Lumen 不会自动发现 provider,所以你要手动注册 binding;scheduler API 略有差异,要用 Lumen\Framework\Console\Scheduling\Schedule

汇率应该多久刷一次? B2B 计费按每天欧洲央行 16:00 CET 定盘后刷一次是行业标准。面向 C 端的结账,盘中 5-15 分钟。交易或财务仪表盘要走实时 WebSocket,REST 轮询永远慢半拍。完整权衡见 免费汇率 API 指南

上游 API 挂了会怎样? 按上面构建的 fallback 链,cache 在 TTL 内继续返回上次的汇率,TTL 过期后数据库返回 7 天内最近的快照,再之后才会失败。实际上一天内的故障里,结账都能正常工作。

跟着教程走需要付费套餐吗? 不需要。Finexly 免费档每月 1,000 次请求、不要信用卡,足够走完每一步。配 15 分钟 TTL,能撑住每天约 30,000 次页面访问才会触配额。


准备把实时汇率接进 Laravel 应用?免费领取 Finexly API key——每月 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 →