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 bucketAnd 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 method | Returns | Action | Notes |
|---|---|---|---|
for($owner) | CreditsScope | — | The entry point for one owner. |
format($amount, $scale, $rounding, $storedScale) | string | FormatCreditsAction | Needs no model. |
currency($bucket) | ?Currency | ResolveBucketCurrencyAction | null = plain credits. |
balance($owner, $bucket, $at) | int | GetCreditsBalanceAction | One bucket. |
total($owner, $buckets, $at) | int | GetCreditsTotalAction | null = every bucket; the list is de-duplicated; [] is 0. |
modify($owner, CreditChangeData $data) | Credit | ModifyCreditsAction | Signed; overdraft-guarded; fires CreditsModified. |
setTo($owner, $amount, $description, $meta, $allowOverdraft, $bucket) | ?Credit | SetCreditsAction | One 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 timeFormatCreditsAction
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 centsResolveBucketCurrencyAction
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_bucketShow 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.