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

DI and actions

Three equivalent entry points run the same code. The facade is the shortest and the recommended default. RoundlyConsulting\Credits\CreditsManager — the facade’s root, an injectable singleton — gives the identical API as an explicit constructor dependency, with no static calls:

use RoundlyConsulting\Credits\CreditsManager;

final class RewardSignup
{
    public function __construct(private CreditsManager $credits) {}

    public function __invoke(User $user): void
    {
        $this->credits->for($user)->bucket('points')->add(100, 'Welcome');
    }
}

The manager also has the flat verbs every scope ends in:

use RoundlyConsulting\Credits\DataTransferObjects\CreditChangeData;

$credits->modify($user, new CreditChangeData(amount: -30, description: 'purchase', bucket: 'points'));
$credits->setTo($user, 500, 'manual adjustment');
$credits->balance($user, bucket: 'points');
$credits->total($user, ['promotional', 'purchased']);           // null = every bucket

And each operation is a single-purpose action with one execute() method, for when you want to resolve and run it yourself — from a job, a command or your own action:

use RoundlyConsulting\Credits\Actions\{FormatCreditsAction, GetCreditsBalanceAction,
    GetCreditsTotalAction, ModifyCreditsAction, ResolveBucketCurrencyAction, SetCreditsAction};

app(ModifyCreditsAction::class)->execute($user, new CreditChangeData(amount: -30, description: 'purchase'));
app(SetCreditsAction::class)->execute($user, 500, 'manual adjustment');
app(GetCreditsBalanceAction::class)->execute($user, bucket: 'points');
app(GetCreditsTotalAction::class)->execute($user, ['promotional', 'purchased']);
app(FormatCreditsAction::class)->execute(123450, scale: 2);
app(ResolveBucketCurrencyAction::class)->execute('store_credit');

Facade method → action

Facade / manager methodReturnsActionNotes
for($owner)CreditsScope—The entry point for one owner.
format($amount, $scale, $rounding, $storedScale)stringFormatCreditsActionNeeds no model.
currency($bucket)?CurrencyResolveBucketCurrencyActionnull = plain credits.
balance($owner, $bucket, $at)intGetCreditsBalanceActionOne bucket.
total($owner, $buckets, $at)intGetCreditsTotalActionnull = every bucket; the list is de-duplicated; [] is 0.
modify($owner, CreditChangeData $data)CreditModifyCreditsActionSigned; overdraft-guarded; fires CreditsModified.
setTo($owner, $amount, $description, $meta, $allowOverdraft, $bucket)?CreditSetCreditsActionOne delta row; null when it already matches.
fake()CreditsFake—Static, on the facade — see Testing.

The manager resolves each action from the container every time it is called, so binding your own implementation of an action changes the behaviour without forking the package. Credits::fake() swaps every injected CreditsManager too — the HasCredits methods and credits:modify included.

ModifyCreditsAction

Records one ledger row inside a database transaction: it takes the owner lock (a bump of the owner’s credit_locks row), reads the bucket’s balance under a row lock, applies the overdraft guard, appends the row and dispatches CreditsModified with the balance the change produced. It takes a CreditChangeData:

use RoundlyConsulting\Credits\DataTransferObjects\CreditChangeData;

$change = new CreditChangeData(amount: 50, description: 'top-up');

$change->resolvedBucket(); // 'default' — config('credits.default_bucket') when bucket is null
$change->toAttributes();   // ['amount' => 50, 'description' => 'top-up', 'meta' => null, 'bucket' => 'default']

SetCreditsAction

Adjusts one bucket to an exact amount through ModifyCreditsAction. It takes the owner lock and reads the balance under a row lock in the same transaction as the write, so racing calls serialise; it returns null when the delta is zero:

// execute(Model&Creditable $creditable, int $amount, ?string $description = null,
//         ?array $meta = null, bool $allowOverdraft = false, ?string $bucket = null): ?Credit
app(SetCreditsAction::class)->execute($user, 500, 'adjustment', null, false, 'promotional');

GetCreditsBalanceAction

Sums one bucket’s ledger rows. Pass lockForUpdate: true inside your own transaction to read under row locks — the same read the overdraft guard uses:

// execute(Model&Creditable $creditable, ?CarbonInterface $at = null, bool $lockForUpdate = false, ?string $bucket = null): int
app(GetCreditsBalanceAction::class)->execute($user, at: now()->subWeek(), bucket: 'promotional');

GetCreditsTotalAction

Sums several buckets, or every bucket the owner has:

// execute(Model&Creditable $creditable, ?array $buckets = null, ?CarbonInterface $at = null): int
$total = app(GetCreditsTotalAction::class);

$total->execute($user, ['promotional', 'purchased']);   // de-duplicated; [] returns 0
$total->execute($user);                                  // null = every bucket
$total->execute($user, at: now()->subWeek());            // ... at a point in time

FormatCreditsAction

The formatting core behind Credits::format() and the display helpers — a thin layer over money-for-laravel’s MinorUnits::rescale() and MinorUnits::toDecimal(). $storedScale defaults to credits.scale; a denominated bucket passes its currency exponent:

use RoundlyConsulting\Credits\Actions\FormatCreditsAction;

// execute(int $amount, ?int $scale = null, ?\RoundingMode $rounding = null, ?int $storedScale = null): string
app(FormatCreditsAction::class)->execute(150, scale: 2, rounding: \RoundingMode::HalfEven); // "1.50" (credits.scale = 2)
app(FormatCreditsAction::class)->execute(1050, storedScale: 2);                            // "10.50" — read as cents

ResolveBucketCurrencyAction

Resolves a bucket’s currency from credits.currencies against money’s registry, on every call. The money-typed helpers throw BucketNotDenominatedException for a bucket it resolves to null:

use RoundlyConsulting\Credits\Actions\ResolveBucketCurrencyAction;

// execute(?string $bucket = null): ?Currency
app(ResolveBucketCurrencyAction::class)->execute('store_credit'); // ?Currency — null for a plain bucket
app(ResolveBucketCurrencyAction::class)->execute();               // null = credits.default_bucket

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.