Skip to content
Packstub

Installation and configuration

composer require packstub/credits
php artisan migrate

The migration creates five tables: credit_wallets (a holder's balance), credit_grants (the lots), credit_holds, credit_transactions (the ledger, append-only) and credit_allocations (which lots each transaction moved). Publish it with php artisan vendor:publish --tag=packstub-credits-migrations to change it before running.

The holder

Any Eloquent model can hold credits. Add the trait:

use Packstub\Credits\Concerns\HasCredits;

class Team extends Model
{
    use HasCredits;
}

$team->credits();          // the "default" wallet, created on first use
$team->credits('sms');     // a second, separate balance
$team->creditWallets;      // all of them

Without the trait: Credits::wallet($team, 'default'). The holder must be saved first; a wallet for an unsaved model throws.

Holders keyed by a UUID or a ULID (HasUuids, HasUlids) work too: set CREDITS_HOLDER_KEY_TYPE=uuid (or ulid) before the first migration, so credit_wallets.holder_id is created as that type instead of a big integer. All holders of one app share the type; changing it later needs a migration of your own on credit_wallets.

Configuration

php artisan vendor:publish --tag=packstub-credits-config
Key Env Default What
holder_key_type CREDITS_HOLDER_KEY_TYPE int Primary key type of the models that hold credits: int, uuid or ulid. Read when the tables are created.
meters [] Rates per meter: 'ai.output_tokens' => ['credits' => 5, 'per' => 1_000]. Different rates per plan with Credits::metersUsing(). See Meters.
hold_ttl CREDITS_HOLD_TTL 900 Seconds a hold lives when placed without a ttl.
refund_grace_days CREDITS_REFUND_GRACE_DAYS 7 Lifetime of the new lot a refund creates for credits whose lot expired meanwhile.
low_balance_threshold CREDITS_LOW_BALANCE_THRESHOLD null BalanceLow fires when available credits fall below it. A wallet's own low_balance_threshold column wins.

The scheduler

Nothing needs a cron to be correct: every operation first expires due lots, releases stale holds and grants the current allowances of its wallet. Schedule credits:expire so wallets nobody touches still expire on time and their events fire:

Schedule::command('credits:expire')->hourly();

SQLite in production

Set 'transaction_mode' => 'IMMEDIATE' on the SQLite connection. See Auditing and concurrency.