florianv/exchanger
PHP exchange rate provider layer exposing 30+ services (fastFOREX, ECB, national banks) through one ExchangeRateService interface, with chain fallback, PSR-16 caching and historical rates. Maintained since 2016; requires PHP 8.2+.
Architecture fit
Exchanger is a provider abstraction layer, not a Laravel package. It sits at the bottom of the florianv stack: laravel-swap → symfony-swap → swap → exchanger. Choosing it directly means we deliberately take ownership of what Swap normally gives us for free: facade/API shape, DI bindings, config surface, cache policy, and chain composition.
That trade is only justified if at least one of these is true: we need non-default chain ordering, we need custom cache-key or TTL strategy per currency pair, we need to wrap providers in our own PSR-18 middleware (rate limiting, request signing, tracing), or we're building an FX surface that other teams consume. If the requirement is simply "convert an amount at today's rate," laravel-swap is the cheaper decision and it pulls Exchanger in transitively — we get the same providers with less code we own.
Positively, the abstraction surface is small and stable: an ExchangeRateService contract, ExchangeRateQueryBuilder for constructing queries, and an ExchangeRate value object exposing getValue(), getDate(), getProviderName(), getCurrencyPair(). That is easy to hide behind our own FxRateProvider interface, which keeps vendor swap-out cheap and keeps the library out of domain code.
The PSR-18 / PSR-17 / PSR-16 posture is the strongest architectural argument. No hard coupling to Guzzle, no hard coupling to a specific cache backend, trivially fakeable in tests. The cost is that nothing is wired up for us in Laravel.
Integration feasibility
composer require florianv/exchanger symfony/http-client nyholm/psr7 resolves cleanly in a modern Laravel app; php-http/discovery (now via Psr18ClientDiscovery as of 2.9.0) finds an installed PSR-18 client, and Laravel ships Guzzle by default. Provider credentials go in config/services.php and .env. No service provider is published, so we write one — roughly 30–60 lines to bind a configured Chain instance.
The one genuine friction point is PSR-16 caching. Laravel's cache repository is not PSR-16. Options are: (a) symfony/cache adapters pointed at Redis/filesystem, which duplicates our cache infrastructure and splits operational tooling; (b) php-cache/simple-cache-bridge, which is the wrong direction (PSR-6 → PSR-16); or (c) a small adapter class implementing Psr\SimpleCache\CacheInterface over Illuminate\Contracts\Cache\Repository. Option (c) is the right call — it keeps TTL, prefixing, and eviction under config/cache.php and keeps our existing Redis/Horizon observability intact.
Technical risk
FxRateProvider wrapper means an exit costs days, not weeks.getValue() returns a float by design. That is fine for display and catalog pricing, not for ledger-grade accounting. We must convert and round with BCMath or brick/money at our boundary and never let a float reach a persisted monetary field.Key questions
laravel-swap defaults satisfy the requirement at lower cost of ownership?Stack fit
Target Laravel 10–12 on PHP 8.2+. Guzzle is present by default, so PSR-18 discovery resolves without extra packages; we can skip symfony/http-client and nyholm/psr7 entirely. Bind the configured Exchanger and Chain as singletons — the service objects carry no per-request mutable state, so this is Octane-safe, but resolve per-request if we ever add stateful middleware. Queue workers and scheduled jobs are fine; only outbound HTTP and the cache are involved.
Compatibility
Verify the CI matrix against our PHP floor. If any supported service still runs 8.1, Exchanger is off the table until that service is upgraded. Check PSR-16 package version constraints against whatever cache adapter we choose, and confirm php-http/discovery major version alignment if we add other HTTP-consuming packages.
Migration path
FxRateProvider interface, implement it with Exchanger, and shadow-run both for one billing cycle, comparing outputs and logging deltas before cutover.laravel-swap: no migration needed — laravel-swap already depends on Exchanger. Moving "down" to Exchanger directly is only worth doing if we need chain/cache control; otherwise stay put and configure Swap.getProviderName() for attribution.Sequencing
config/fx.php with provider list, per-provider credentials, cache TTL, timeout, and staleness policy. Service provider binds the chain.Maintenance
Ongoing cost is low but not zero — budget roughly 0.1 FTE: a monthly dependency review, a read of release notes on every minor (the 2.9.0 pattern suggests minor versions are not always operationally inert), and reactive fixes when a provider endpoint drifts. Provider breakage will be discovered by us, not by upstream, so our alerting on "conversion failed" or "fallback depth > 0" is the real maintenance mechanism.
Support
Community-only. No SLA, no vendor to escalate to, upstream GitHub issues as the sole channel. Our on-call owns integration failures; the maintainer owns provider parsing bugs. For paid providers, the provider's own support is a separate, better channel — another argument for at least one contracted provider in the chain.
Scaling
Lookups are cache-dominated: one entry per currency pair per date granularity. The ceiling is provider rate limits, not our application tier. Two scaling hazards: (a) the fallback path is sequential, so a fully-down chain costs N × timeout per request — set 1–2s per-provider timeouts and a short negative cache; (b) TTL expiry creates a stampede on hot pairs — use cache locks or probabilistic early refresh. For high-traffic checkout paths, warm known pairs on a schedule rather than fetching inline.
Failure modes to design for
ChainException carrying all collected exceptions. Must degrade to a persisted last-known-good rate with an explicit staleness policy, not a 500.getDate() is the provider's rate date, not our accounting date. Cross-border month-end close will expose this.Ramp-up
A competent Laravel developer ships a working EUR→USD conversion in under a day; production-grade caching, chaining, and observability take about a week. Documentation is adequate — a solid README, a deeper doc/readme.md covering caching/HTTP/error handling, plus a changelog and release notes — but there is nothing Laravel-specific, so most of the learning curve is provider quirks (base-currency restrictions, granularity, historical coverage, key handling) rather than the library API itself. Budget for that explicitly in the spike; it is where the surprises live.
How can I help you explore Laravel packages today?