只要 Laravel 应用涉及金钱,迟早都会需要汇率——无论是用三种货币开发票、把 Stripe 收入折回到本币,还是在结账页显示本地化价格。最简单的做法就是接入一个 Laravel 汇率 API,把 FX 当成已经解决的问题。难点在于做对:缓存要够激进以免烧光配额;要把汇率持久化,让上游 5xx 不至于让结账崩溃;刷新要对齐欧洲央行 16:00 CET 定盘;测试不能打真实网络。
本文把这些都过一遍。我们将基于 Laravel 11、Finexly 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 种主流币。哪怕只有一个客户付阿根廷比索或土耳其里拉,差距就显出来了。
- 统一契约。
latest、historical、convert三个端点同一种 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=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'),
],还没有 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>
@endisset40 行 Blade 加一个 controller,一个完整的用户侧转换器。如果你更想直接用现成版本,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 列表缓存起来:
$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 再做决定。
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 →