Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Balance Laravel Package

illuminatech/balance

Laravel package for managing balances tied to Eloquent models. Supports deposits, withdrawals, holds, transfers, and transaction history with atomic, concurrency-safe updates—ideal for wallets, points, credits, and virtual currency.

View on GitHub
Deep Wiki
Context7

Getting Started

Install the package and publish its migrations before writing any domain code:

composer require illuminatech/balance
php artisan vendor:publish --provider="Illuminatech\Balance\BalanceServiceProvider"
php artisan migrate

You get two tables out of the box: balances (one row per accounted entity, holding the current amount) and balance_transactions (an append-only ledger of every debit and credit that produced that amount). Everything else in the package is a thin Eloquent layer over those two tables.

The first thing to read is the published config/balance.php. That file is where the model classes, table names, connection, and the attribute name mapping live — the package does not hardcode amount anywhere, so if you plan to call the column balance or credits you change it there, not in your models.

Your first use case should be the simplest one that mirrors production: a user wallet.

use Illuminatech\Balance\Balance;

$wallet = Balance::create(['userId' => $user->id, 'amount' => 0]);

$wallet->increase(5000);   // top-up: writes a credit transaction
$wallet->decrease(1200);   // purchase: writes a debit transaction

$wallet->amount;           // 3800
$wallet->transactions;     // full history, ordered

Treat increase() / decrease() as the only sanctioned way to move money. Once you've done a top-up and a purchase and confirmed you get two balance_transactions rows plus a correct running total, you understand the package.

Implementation Patterns

Wrap the balance row in your own model. Subclass the package Balance (or point the config at your subclass) so you can attach relations, scopes, and accessors. This is the intended extension point — you keep increase/decrease/transfer and gain $wallet->user, scopeActive, etc.

Create-or-fetch as an atomic step. Balances are per-entity singletons, so use firstOrCreate keyed on your foreign key (e.g. userId) rather than create() on every request. Doing this inside a transaction avoids the double-insert race when a user's first two requests land simultaneously.

Transfers for internal movement. Moving value between two balances (user → escrow, wallet → wallet) should go through the transfer API rather than two independent decrease/increase calls, so the pair is written together and the ledger stays balanced. If you must pair them by hand, put both inside a single DB::transaction() and let the exception roll both back.

Use the data JSON column as your idempotency and audit channel. Every transaction accepts an arbitrary payload. Store the originating order ID, payment gateway reference, and actor ('order_id' => $order->id) there. On retries, look up the existing transaction by that reference before calling increase again — the package is not idempotent by itself, and a webhook delivered twice will double-credit. A unique index on (balance_id, data->>'reference') (or a dedicated reference column added by your own migration) is the cheapest guard.

Reconcile from the ledger, not the balance column. The amount on balances is a cached running total. Write a scheduled command that sums balance_transactions grouped by balance and compares to the stored figure. When they diverge you have a bug (direct save() on the amount, a manual SQL update, a rolled-back transaction) — the ledger is the source of truth.

Compose with domain events. The package fires no domain events of its own. After a successful increase/decrease, dispatch your own (WalletCredited, WalletDebited) outside the DB transaction via DB::afterCommit or an event listener, so notifications and downstream jobs never fire for a rolled-back movement.

Reporting. Query balance_transactions directly with whereBetween('created_at', ...) and groupBy for statements. The type column distinguishes debit from credit, which is what you want for a two-column statement view rather than trying to infer sign from context.

Gotchas and Tips

Amounts are integers in minor units. Do not pass floats or decimal strings. increase(19.99) will either throw or silently store garbage depending on your column type. Keep a single boundary layer where currency is formatted for display and parsed back to integer cents.

Never mutate the amount column directly. $wallet->amount = 100; $wallet->save(); updates the cache and writes no transaction row, permanently desynchronising your ledger. If you need to correct a balance, post an adjusting transaction through the API and record why in data.

Corrections are new transactions, never deletions. The design is double-entry: you reverse a mistake with an offsetting entry. Deleting a balance_transactions row breaks auditability and your reconciliation job will start failing.

Concurrency relies on row locking on the configured connection. The package locks the balance row during mutation. If your app uses multiple connections (read replicas, separate reporting DB), make sure the config points at the write connection — otherwise the lock is taken against a replica and you get lost updates under load. Test with parallel requests, not sequential ones.

Negative balances are allowed by default. If your domain forbids overdrafts, enforce it in a domain service that checks $wallet->amount >= $amount inside the same transaction immediately before decrease(). Doing the check in a controller leaves a race window.

virtual_amount is for reserved/pending value, not a second wallet. If configured, the second column is updated in lockstep with the primary one, which is how you model "available" vs "total including holds". Don't try to use it as an independent balance — it shares the transaction history.

Publish and own the migrations. The package ships migrations you publish; edit the copy in database/migrations to add your indexes (especially on your owner foreign key and on created_at for statements) rather than patching the vendor files. Vendor migrations will not be updated for you.

Eager-load transactions for any list view. Rendering a page of balances and their recent history naively is an N+1 disaster. Balance::with(['transactions' => fn ($q) => $q->latest()->limit(10)]) or a dedicated query on the transaction model.

Debugging drift starts with a single balance's history. When a total looks wrong, dump $wallet->transactions()->orderBy('id')->get(['id','type','amount','data','created_at']) and replay it mentally — the ledger is small and human-readable precisely so you can do this. If the replayed total matches the ledger but not balances.amount, the bug is a direct write somewhere; grep for ->amount = and raw update statements.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle
glavweb/extra-bundle
glavweb/api-bundle
glavweb/admin-bundle
adachsoft/api-version-bundle
elsayed85/laravel-api-exceptions
ouhssini/laravel-demo-mode
daworks/ncloud-outbound-mailer-driver
gp247/cms
ignibyte/aic-gates
nexa-merchant/blog
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
shitoudev/permissionmanager