florianv/swap-bundle
Symfony bundle for the Swap currency exchange library. It integrates exchange-rate providers into Symfony, enabling currency conversion, rate lookup, and configurable provider and caching setup through a simple service-oriented API.
First, the mismatch that shapes everything: florianv/swap-bundle is a Symfony bundle. Laravel has no auto-discovery for it, no swap: config tree wiring, and no Swap\Swap service in the container. What you actually install in a Laravel app is the library it wraps:
composer require florianv/swap
Heads-up on the 6.0.0 release: the version bump that just shipped is for florianv/swap-bundle (symfony-swap), not florianv/swap. Its changelog is entirely Symfony-side: PHP ^8.2 required, Symfony ^6.4 | ^7.0 | ^8.0, php-http/guzzle6-adapter swapped for guzzle7-adapter, FlorianvSwapExtension::load() signature modernised, PHPUnit 11 / Psalm level 1 internals. None of that reaches a Laravel app, because you never install the bundle. The one practical consequence: the bundle is no longer readable on a PHP 8.1 box, so if you keep it in a scratch checkout purely as reference, that checkout now needs PHP 8.2+. The core florianv/swap library is unchanged by this release — don't let the 6.0.0 headline push you into an unnecessary major bump of the library, which has its own independent version line.
The bundle is still useful as documentation: its DependencyInjection/Configuration.php and service definitions show exactly which constructor arguments each provider expects (access keys, base currency, HTTP client, cache) — mirror that in your own provider. Post-6.0 the Configuration.php is simpler (the Symfony <4.2 compatibility branches were deleted), which actually makes it a cleaner reference. If you ever inherit a Symfony app using it, the bundle exposes Swap\Swap as an autowired service and an ordered swap.providers list; that's the mental model to copy.
Where to look first, in this order:
vendor/florianv/swap/README.md — provider list and Swap\Builder API.vendor/florianv/swap/src/Builder.php — the real contract; it changes between majors.vendor/florianv/swap/src/Provider/ — one class per data source, tells you the exact options array.vendor/florianv/swap/src/ExchangeRate.php — what you actually get back.Minimal wiring — config/swap.php:
return [
'default' => env('SWAP_PROVIDER', 'fixer'),
'providers' => [
'fixer' => ['access_key' => env('FIXER_KEY')],
'exchange_rates_api' => ['access_key' => env('EXCHANGE_RATES_API_KEY')],
'european_central_bank' => [], // no key, EUR base only, no weekend rates
],
'cache_ttl' => 3600,
];
Then bind it once in AppServiceProvider::register():
$this->app->singleton(\Swap\Swap::class, function ($app) {
return (new \Swap\Builder())
->add('fixer', config('swap.providers.fixer'))
->add('european_central_bank')
->useCache(new \App\Support\LaravelPsr16Cache($app['cache']->store()))
->build();
});
First use case: price display and checkout in a second currency.
$rate = app(\Swap\Swap::class)->latest('EUR/USD');
$usd = app(\Swap\Swap::class)->convert(new Money(1999, new Currency('EUR')), 'USD');
Wrap the vendor class — do not inject Swap\Swap into controllers. The library is small, the API has churned across majors, and you will want a last-known-rate fallback and an audit trail. One App\Services\CurrencyConverter with convert(Money $m, string $to): Money and rate(string $base, string $quote): float lets you swap the implementation (or fake it) later.
PSR-16 cache bridge. Swap v4 wants Psr\SimpleCache\CacheInterface; Laravel's repository isn't PSR-16. A ~40-line adapter is the whole integration:
final class LaravelPsr16Cache implements \Psr\SimpleCache\CacheInterface
{
public function __construct(private \Illuminate\Contracts\Cache\Repository $cache) {}
private function k(string $key): string
{
// PSR-16 reserves {}()/\@: — Swap uses "EUR/USD" style keys
return 'swap:'.str_replace(['/','\\','{','}','(',')','@',':'], '_', $key);
}
public function get(string $key, mixed $default = null): mixed
{ return $this->cache->get($this->k($key), $default); }
public function set(string $key, mixed $value, null|int|\DateInterval $ttl = null): bool
{
$seconds = $ttl instanceof \DateInterval
? (new \DateTimeImmutable())->add($ttl)->getTimestamp() - time()
: (int) ($ttl ?? config('swap.cache_ttl'));
return $this->cache->put($this->k($key), $value, $seconds);
}
// delete, clear, getMultiple, setMultiple, deleteMultiple, has — trivial passthroughs
}
Without key sanitization, strict PSR-16 implementations throw InvalidArgumentException on the first EUR/USD lookup.
Provider chain as your fallback story. Register providers in priority order; the builder tries each until one succeeds. Put a keyed provider first (Fixer, ExchangeRatesApi, CurrencyLayer, OpenExchangeRates) and european_central_bank last — it needs no credentials and survives quota exhaustion, at the cost of EUR-only and no weekend/holiday quotes.
Layer a database fallback below the chain. Catch the package exception hierarchy (catch (\Swap\Exception\Exception $e)) and read the last persisted rate from a exchange_rates table with a fetched_at column. Persist on every successful fetch — this is also your audit log for refunds and accounting.
Warm the cache on a schedule, not on requests. Exchange rates have no business being fetched during a user's checkout:
// routes/console.php
Schedule::command('swap:warm EUR,USD,GBP,CHF')->hourly();
Inside the command, guard against stampedes with Cache::lock('swap:warm', 60)->block(5, fn () => ...) and log the provider name and rate date for each pair.
Store money as minor units plus the rate used. On an order, write total_minor, currency, base_total_minor, rate_used, rate_date, rate_provider. Without rate_used you cannot reproduce an invoice or process a partial refund six weeks later.
Format at the edge only. Keep float/string precision internally, round once at presentation via moneyphp's IntlMoneyFormatter, and expose a Blade component rather than scattering number_format calls.
Testing: bind a fake converter in the container ($this->app->instance(CurrencyConverter::class, new FakeConverter())) — that's far more stable than trying to stub the vendor. For HTTP-level provider tests, note the providers use PSR-18/Guzzle; Http::buildClient() returns a Guzzle client that respects Http::fake(), so you can pass it into the provider options in tests. Verify the option name in the provider constructor first.
composer require florianv/swap-bundle in Laravel. It pulls Symfony DI/HttpKernel packages and will never boot. Only the library belongs in a Laravel app. The 6.0.0 release doubles down on this: it now requires Symfony ^6.4 minimum (dropping 3.x/4.x/5.x entirely) and PHP ^8.2 — it is more Symfony-shaped than ever, not more framework-agnostic.swap-bundle 6.0.0 says nothing about florianv/swap's version. If you see a Dependabot PR bumping the bundle in a Laravel repo, it's a straggler from a copy-pasted recipe — remove the dependency rather than merging it.getValue() may return a string, not a float. Cast explicitly ((float) $rate->getValue()) and don't do arithmetic on the raw return. Precision is the whole point of the type.getDate() is the rate's publication date, not "now". ECB publishes nothing on weekends or TARGET holidays, so latest('EUR/USD')->getDate() on a Sunday is Friday. Never compare it to now() for staleness logic — compare to your own fetched_at.GBP/JPY yourself from GBP/USD and USD/JPY, and do the multiplication in bcmath or with 6+ decimals.new Currency('BTC') is fine in moneyphp (it doesn't validate against ISOCurrencies unless you ask), but formatting via IntlMoneyFormatter will fail for non-ISO codes — branch on that.'usd' from a form request produces a confusing provider error. Normalize at the boundary.ExchangeRate objects is fine (they're plain value objects), but the cache key is built from base/quote — which is why the slash issue above bites everyone exactly once.florianv/swap moves slowly and has a single maintainer; treat it as a leaf dependency behind your own interface so a future major bump is a one-file change. (When you do audit it, note the last round of movement in this family was on the bundle side, not the library — so an untouched swap constraint is expected, not neglect.)Builder — custom provider, custom HTTP client, custom cache. There's no Laravel-level config to extend, so resist the urge to build a package around it; a service class and a config file are the right size. If you port service definitions from the bundle, take the post-6.0 ones: the Guzzle adapter is now php-http/guzzle7-adapter, so don't copy older snippets that reference the v6 adapter.How can I help you explore Laravel packages today?