A ledger-based Laravel package for managing credit-based systems in your application. Perfect for virtual currencies, reward points, or any credit-based feature.
- Laravel Credits
- Table of Contents
- Features
- Installation
- Configuration
- Usage
- API Reference
- Testing
- Changelog
- Contributing
- Security Vulnerabilities
- Sponsors
- Star History
- License
- Disclaimer
- 🔄 Credit transactions
- 💸 Credit transfers
- 📢 Events for adding, deducting, and transferring credits
- 💰 Balance tracking with running balance
- 📊 Transaction history
- 🔍 Point-in-time balance lookup
- 📝 Transaction metadata support
- 🔎 Powerful metadata querying with filters and scopes
- ⚡ Efficient queries using running balance and indexes
- 🚀 Performance optimization guide for high-volume applications
You can install the package via composer:
composer require climactic/laravel-creditsPublish and run the migrations:
php artisan vendor:publish --tag="credits-migrations"
php artisan migrateOptionally publish the config file:
php artisan vendor:publish --tag="credits-config"return [
// Allow negative balances
'allow_negative_balance' => false,
// Table name for credit transactions (change if you've updated the migration table name)
'table_name' => 'credits',
];Concurrency & Locking: This package uses row-level locking (SELECT FOR UPDATE) to prevent race conditions during concurrent credit operations. This requires a database engine that supports proper transaction isolation and row-level locking:
- ✅ MySQL/MariaDB: Requires InnoDB engine (default in modern versions)
- ✅ PostgreSQL: Full support for row-level locking
⚠️ SQLite: Row-level locking is ignored; concurrent operations may produce incorrect results in high-concurrency scenarios
For production environments with concurrent users, we recommend using MySQL/MariaDB (InnoDB) or PostgreSQL.
Add the HasCredits trait to any model that should handle credits:
use Climactic\Credits\Traits\HasCredits;
class User extends Model
{
use HasCredits;
}// Add credits
$user->creditAdd(100.00, 'Subscription Activated');
// Deduct credits
$user->creditDeduct(50.00, 'Purchase Made');
// Get current balance
$balance = $user->creditBalance();
// Check if user has enough credits
if ($user->hasCredits(30.00)) {
// Proceed with transaction
}Transfer credits between two models:
$sender->creditTransfer($recipient, 100.00, 'Paying to user for their service');// Get last 10 transactions
$history = $user->creditHistory();
// Get last 20 transactions in ascending order
$history = $user->creditHistory(20, 'asc');Get balance as of a specific date:
$date = new DateTime('2023-01-01');
$balanceAsOf = $user->creditBalanceAt($date);Add additional information to transactions:
$metadata = [
'order_id' => 123,
'product' => 'Premium Subscription',
'user_id' => 456,
'tags' => ['premium', 'featured']
];
$user->creditAdd(100.00, 'Purchase', $metadata);The package provides powerful query scopes to filter transactions by metadata with built-in input validation for security.
Metadata keys must follow these rules:
- Use dot notation for nested keys (e.g.,
'user.id', not'user->id') - Cannot be empty or contain only whitespace
- Cannot contain quotes (
"or') - Whitespace is automatically trimmed
// ✅ Valid
$user->credits()->whereMetadata('source', 'purchase')->get();
$user->credits()->whereMetadata('user.id', 123)->get();
$user->credits()->whereMetadata(' source ', 'test')->get(); // Trimmed automatically (not recommended)
// ❌ Invalid - will throw InvalidArgumentException
$user->credits()->whereMetadata('', 'value')->get(); // Empty key
$user->credits()->whereMetadata('data->key', 'value')->get(); // Arrow operator
$user->credits()->whereMetadata('data"key', 'value')->get(); // Contains quotes// Query by simple metadata value
$purchases = $user->credits()
->whereMetadata('source', 'purchase')
->get();
// Query with comparison operators
$highValue = $user->credits()
->whereMetadata('order_value', '>', 100)
->get();
// Query by nested metadata (using dot notation)
$specificUser = $user->credits()
->whereMetadata('user.id', 123)
->get();
// Deeply nested keys (use raw where for very deep nesting)
$results = $user->credits()
->where('metadata->data->level1->level2->value', 'found')
->get();// Check if metadata array contains a value
$premium = $user->credits()
->whereMetadataContains('tags', 'premium')
->get();
// Check if metadata key exists
$withOrderId = $user->credits()
->whereMetadataHas('order_id')
->get();
// Check if metadata key is null or doesn't exist
$withoutTags = $user->credits()
->whereMetadataNull('tags')
->get();
// Query by metadata array length
$multipleTags = $user->credits()
->whereMetadataLength('tags', '>', 1)
->get();$filtered = $user->credits()
->whereMetadata('source', 'purchase')
->whereMetadata('category', 'electronics')
->whereMetadataContains('tags', 'featured')
->where('amount', '>', 50)
->get();// Get credits filtered by metadata (with limit and ordering)
$purchases = $user->creditsByMetadata('source', 'purchase', limit: 20, order: 'desc');
// With comparison operators
$highValue = $user->creditsByMetadata('order_value', '>=', 100, limit: 10);
// Multiple metadata filters
$filtered = $user->creditHistoryWithMetadata([
['key' => 'source', 'value' => 'purchase'],
['key' => 'category', 'value' => 'electronics'],
['key' => 'tags', 'value' => 'premium', 'method' => 'contains'],
['key' => 'order_value', 'operator' => '>', 'value' => 50],
], limit: 25);
// Query with null values (explicitly include 'value' key)
$nullChecks = $user->creditHistoryWithMetadata([
['key' => 'refund_id', 'operator' => '=', 'value' => null], // Check for null
['key' => 'status', 'value' => 'pending'], // Simple equality
], limit: 10);Note: When using creditHistoryWithMetadata(), the filter array distinguishes between "two-parameter syntax" and "three-parameter syntax" by checking if the 'value' key exists:
- If
'value'key exists: uses three-parameter formwhereMetadata($key, $operator, $value) - If
'value'key missing: uses two-parameter formwhereMetadata($key, $operator)where operator becomes the value
This allows proper handling of null values while maintaining shorthand syntax convenience.
For high-volume applications querying metadata frequently, consider adding database indexes. Without indexes, metadata queries perform full table scans. With proper indexes, queries become nearly instant even with millions of records.
- Small datasets (< 10k transactions): No optimization needed
- Medium datasets (10k - 100k): Consider optimization for frequently queried keys
- Large datasets (> 100k): Highly recommended for any metadata queries
Virtual generated columns extract JSON values into indexed columns for fast queries:
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
return new class extends Migration
{
public function up()
{
$tableName = config('credits.table_name', 'credits');
// Add virtual columns for frequently queried metadata keys
DB::statement("
ALTER TABLE {$tableName}
ADD COLUMN metadata_source VARCHAR(255)
GENERATED ALWAYS AS (JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.source'))) VIRTUAL
");
DB::statement("
ALTER TABLE {$tableName}
ADD COLUMN metadata_order_id BIGINT
GENERATED ALWAYS AS (JSON_EXTRACT(metadata, '$.order_id')) VIRTUAL
");
// Add indexes on virtual columns
Schema::table($tableName, function (Blueprint $table) {
$table->index('metadata_source');
$table->index('metadata_order_id');
});
// Optional: Composite indexes for common query combinations
Schema::table($tableName, function (Blueprint $table) {
$table->index(['metadata_source', 'created_at']);
$table->index(['creditable_id', 'creditable_type', 'metadata_order_id']);
});
}
public function down()
{
$tableName = config('credits.table_name', 'credits');
Schema::table($tableName, function (Blueprint $table) {
$table->dropIndex(['metadata_source', 'created_at']);
$table->dropIndex(['creditable_id', 'creditable_type', 'metadata_order_id']);
$table->dropIndex(['metadata_source']);
$table->dropIndex(['metadata_order_id']);
});
DB::statement("ALTER TABLE {$tableName} DROP COLUMN metadata_source");
DB::statement("ALTER TABLE {$tableName} DROP COLUMN metadata_order_id");
}
};Performance impact: Queries go from scanning millions of rows to using index lookups (1000x+ faster).
PostgreSQL's GIN (Generalized Inverted Index) provides efficient querying for all JSON operations:
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up()
{
$tableName = config('credits.table_name', 'credits');
// GIN index supports all JSON operators (@>, ?, ?&, ?|, etc.)
DB::statement("
CREATE INDEX {$tableName}_metadata_gin_idx
ON {$tableName} USING GIN (metadata)
");
// Optional: Add specific path indexes for even faster queries
DB::statement("
CREATE INDEX {$tableName}_metadata_source_idx
ON {$tableName} ((metadata->>'source'))
");
DB::statement("
CREATE INDEX {$tableName}_metadata_order_id_idx
ON {$tableName} ((metadata->'order_id'))
");
}
public function down()
{
$tableName = config('credits.table_name', 'credits');
DB::statement("DROP INDEX IF EXISTS {$tableName}_metadata_gin_idx");
DB::statement("DROP INDEX IF EXISTS {$tableName}_metadata_source_idx");
DB::statement("DROP INDEX IF EXISTS {$tableName}_metadata_order_id_idx");
}
};Note: The GIN index alone enables fast queries for all metadata keys. Path-specific indexes provide marginal additional performance.
SQLite has limited JSON indexing capabilities. For SQLite:
- Metadata queries work but will be slower on large datasets
- Consider using a different database for production if metadata querying is critical
- JSON1 extension must be enabled (available in SQLite 3.38+)
Index metadata keys that you query frequently:
// If you often query by source
$user->credits()->whereMetadata('source', 'purchase')->get();
// → Index: metadata_source
// If you filter by order_id
$user->credits()->whereMetadata('order_id', 12345)->get();
// → Index: metadata_order_id
// If you query by tags array
$user->credits()->whereMetadataContains('tags', 'premium')->get();
// → MySQL: Index on virtual column for tags
// → PostgreSQL: GIN index handles this automatically- Analyze your queries first: Use
EXPLAINto identify slow queries - Index selectively: Only index frequently queried keys (each index adds storage overhead)
- Use composite indexes: For queries combining metadata with other columns
- Test with production data: Benchmark before and after indexing
- Monitor index usage: Remove unused indexes to save storage
Events are fired for each credit transaction, transfer, and balance update.
The events are:
CreditsAddedCreditsDeductedCreditsTransferred
| Method | Description |
|---|---|
creditAdd(float $amount, ?string $description = null, array $metadata = []) |
Add credits to the model |
creditDeduct(float $amount, ?string $description = null, array $metadata = []) |
Deduct credits from the model |
creditBalance() |
Get the current balance |
creditTransfer(Model $recipient, float $amount, ?string $description = null, array $metadata = []) |
Transfer credits to another model |
creditHistory(int $limit = 10, string $order = 'desc') |
Get transaction history |
hasCredits(float $amount) |
Check if model has enough credits |
creditBalanceAt(Carbon|DateTimeInterface|int $dateTime) |
Get balance at a specific time |
credits() |
Eloquent relationship to credit transactions |
creditsByMetadata(string $key, $operator, $value = null, int $limit = 10, string $order = 'desc') |
Get credits filtered by metadata key/value |
creditHistoryWithMetadata(array $filters, int $limit = 10, string $order = 'desc') |
Get credits filtered by multiple metadata |
These scopes can be used on the credits() relationship:
| Scope | Description |
|---|---|
whereMetadata(string $key, $operator, $value = null) |
Filter by metadata key/value |
whereMetadataContains(string $key, $value) |
Filter where metadata array contains value |
whereMetadataHas(string $key) |
Filter where metadata key exists |
whereMetadataNull(string $key) |
Filter where metadata key is null/doesn't exist |
whereMetadataLength(string $key, $operator, $value) |
Filter by metadata array length |
The following methods are deprecated and will be removed in v2.0. They still work but will trigger deprecation warnings:
| Deprecated Method | Use Instead |
|---|---|
addCredits() |
creditAdd() |
deductCredits() |
creditDeduct() |
getCurrentBalance() |
creditBalance() |
transferCredits() |
creditTransfer() |
getTransactionHistory() |
creditHistory() |
hasEnoughCredits() |
hasCredits() |
getBalanceAsOf() |
creditBalanceAt() |
creditTransactions() |
credits() |
composer testPlease see CHANGELOG for more information on what has changed recently.
Please see CONTRIBUTING for details. You can also join our Discord server to discuss ideas and get help: Discord Invite.
Please report security vulnerabilities to [email protected].
GitHub Sponsors: @climactic
To become a title sponsor, please contact [email protected].
The MIT License (MIT). Please see License File for more information.
This package is not affiliated with Laravel. It's for Laravel but is not by Laravel. Laravel is a trademark of Taylor Otwell.