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.