Credits::fake() swaps the manager with a recorder that writes no ledger row and fires no event, and returns it. The swap covers the facade, every injected CreditsManager, the HasCredits model methods and credits:modify:
use RoundlyConsulting\Credits\Facades\Credits;
$fake = Credits::fake();
// ... run the code under test ...
$fake->assertAdded($user, 100, bucket: 'points'); // amount and bucket optional
$fake->assertDeducted($user, 30); // the positive amount, as passed to deduct()
$fake->assertSet($user, 0); // the requested target
$fake->assertNothingAdded();
$fake->assertNothingDeducted();
$fake->assertNothingSet();
$fake->assertNothingModified(); // no add, deduct or set at all- assertAdded($owner, ?$amount, ?$bucket) / assertNothingAdded() — grants, by owner and optionally amount and bucket.
- assertDeducted($owner, ?$amount, ?$bucket) / assertNothingDeducted() — deductions, by the positive amount as passed to deduct().
- assertSet($owner, ?$amount, ?$bucket) / assertNothingSet() — setTo() calls, by the requested target.
- assertNothingModified() — no add, deduct or set at all.
The fake keeps an in-memory ledger. Balances add it to the owner’s real rows, so a grant made on the fake can be spent on the fake. The overdraft guard still applies: a deduction the real manager would refuse throws InsufficientCreditsException and is not recorded. setTo() is recorded even when the balance already matches.
Against a real database
When the test is about the ledger itself, run against a real database with RefreshDatabase — the published migration runs like any other. Write through the public API and assert through it:
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use RoundlyConsulting\Credits\Events\CreditsModified;
use RoundlyConsulting\Credits\Exceptions\InsufficientCreditsException;
uses(RefreshDatabase::class);
it('grants and spends credits', function () {
$user = User::factory()->create();
$user->modifyCredits(100, 'signup bonus');
$user->modifyCredits(-30, 'purchase', ['order_id' => 42]);
expect($user->creditsBalance())->toBe(70)
->and($user->hasCredits(50))->toBeTrue()
->and($user->credits()->count())->toBe(2);
});
it('refuses to overdraw and writes nothing', function () {
$user = User::factory()->create();
$user->modifyCredits(20);
expect(fn () => $user->modifyCredits(-50))
->toThrow(InsufficientCreditsException::class);
expect($user->creditsBalance())->toBe(20)
->and($user->credits()->count())->toBe(1);
});
it('announces every change', function () {
Event::fake([CreditsModified::class]);
$user = User::factory()->create();
$user->modifyCredits(10, 'welcome');
Event::assertDispatched(
CreditsModified::class,
fn (CreditsModified $event) => $event->balance === 10 && $event->creditable->is($user),
);
});Point-in-time balances
created_at drives past balances, so Laravel’s time travel is all you need:
it('reconstructs a past balance', function () {
$user = User::factory()->create();
$this->travelTo(now()->subWeek());
$user->modifyCredits(100);
$this->travelBack();
$user->modifyCredits(-40);
expect($user->creditsBalance(now()->subDays(3)))->toBe(100)
->and($user->creditsBalance())->toBe(60);
});Factory
Credit ships with a factory for seeding ledger rows directly. Attach it to an owner through the creditable relation:
use RoundlyConsulting\Credits\Models\Credit;
// The factory picks a random amount (-1000..1000) unless you set one.
Credit::factory()->for($user, 'creditable')->create(['amount' => 250, 'bucket' => 'promotional']);Rows seeded through the factory bypass the overdraft guard and dispatch no CreditsModified event — use modifyCredits() when the test is about those.
Engines
SQLite accepts a uuid in an integer column silently. If you changed key_type or primary_key_type, run at least part of your suite on the engine you deploy to.
The package’s own suite
To run the package’s test suite from a checkout:
composer testShow 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 cryptoBy 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.