NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages

Exchange::fake($rates, $pivot) swaps in static rates — no HTTP, no cache — for the facade, an injected ExchangeManager and Money::convertTo(), and returns the fake to assert against:

use RoundlyConsulting\Money\Facades\Exchange;
use RoundlyConsulting\Money\Money;

it('converts the order total to dollars', function () {
    $fake = Exchange::fake(['EUR/USD' => '1.0854']);

    $usd = Money::ofMajor('10', 'EUR')->convertTo('USD');

    expect($usd->equals(Money::ofMajor('10.85', 'USD')))->toBeTrue();
    $fake->assertRateRequested('EUR', 'USD');
});

it('never asks for a rate within one currency', function () {
    $fake = Exchange::fake();

    Money::ofMajor('10', 'EUR')->convertTo('EUR');

    $fake->assertNothingRequested();
});

Money::convertTo(), MoneyBag::total() and every converter resolved afterwards see the fake. Missing pairs are inverted or triangulated through the pivot; otherwise ExchangeRateUnavailable. Rates can also be base-keyed: ['EUR' => ['USD' => '1.0854']].

The rates table is recorded, not written

Under the fake, Exchange::rates() records instead of fetching, writing, queueing or deleting — and so do the money:rates:refresh and money:rates:prune commands. source() and extend() keep the real behaviour, so a raw ecb source still downloads; pair it with Http::fake(). An unknown method throws BadMethodCallException.

use RoundlyConsulting\Money\Facades\Exchange;

it('stores the manual CZK rate', function () {
    $fake = Exchange::fake();

    Exchange::rates()->manual('EUR', 'CZK', '25.10');   // recorded — nothing is written

    $fake->assertStored('EUR', 'CZK', '25.10', 'manual');
    $fake->assertNothingRefreshed();
});

Every assertion

use RoundlyConsulting\Money\Enums\EcbFeed;

$fake = Exchange::fake(['EUR/USD' => '1.0854']);

// ... code that converts, refreshes, stores or prunes ...

$fake->assertRateRequested('EUR', 'USD');             // rate() was asked for the pair — conversions included
$fake->assertRefreshed('ecb', EcbFeed::Daily);        // rates()->refresh() ran — source and feed optional
$fake->assertRefreshQueued('ecb');                    // rates()->refreshLater() ran — source and feed optional
$fake->assertStored('EUR', 'CZK', '25.10', 'manual'); // store() or manual() got the pair — rate and source optional
$fake->assertPruned($before);                         // rates()->prune() ran — cut-off day optional

// each has a twin that passes when nothing happened
$fake->assertNothingRequested();
$fake->assertNothingRefreshed();
$fake->assertNothingQueued();
$fake->assertNothingStored();
$fake->assertNothingPruned();

Exchange::rates()->assertStored('EUR', 'CZK');        // the rates-table asserts also live on the faked rates()
AssertPasses whenTwin
assertRateRequested($from, $to)rate() was asked for the pair — conversions included.assertNothingRequested()
assertRefreshed(?$source, ?$feed)rates()->refresh() ran, optionally for this source and feed.assertNothingRefreshed()
assertRefreshQueued(?$source, ?$feed)rates()->refreshLater() ran.assertNothingQueued()
assertStored($from, $to, ?$rate, ?$source)store() or manual() got a rate for the pair; the rate is compared exactly.assertNothingStored()
assertPruned(?$before)rates()->prune() ran, optionally with this cut-off day.assertNothingPruned()

Currencies has no fake: the registry is in-memory and deterministic.

Seeding and faking sources

use Illuminate\Support\Facades\Http;
use RoundlyConsulting\Money\Models\CurrencyRate;

// Seed the rates table for the database driver (run the published migration in your test setup)
CurrencyRate::factory()->pair('EUR', 'USD', '1.10')->on('2026-09-25')->from('ecb')->create();

// Fake the ECB feed for the ecb driver
Http::fake(['*eurofxref-daily.xml' => Http::response($xml)]);

Tips

  • Build amounts with ofMinor() or ofMajor() and assert with equals() or minor() — both exact.
  • For byte-exact formatting assertions use the decimal formatter; with intl, normalize whitespace — ICU versions swap NBSP and NNBSP.
  • Freeze time with CarbonImmutable::setTestNow() for exchange tests — an undated lookup takes “today” in money.exchange.timezone.
<!-- phpunit.xml — byte-exact formatting on every machine -->
<php>
    <env name="MONEY_FORMATTER" value="decimal"/>
</php>

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.