climactic/laravel-credits
Ledger-based credits for Laravel: manage virtual currencies, reward points, and other credit systems with deposits, withdrawals, transfers, transaction history, historical balances, and metadata-powered querying.
A ledger-based Laravel package for managing credit-based systems in your application. Perfect for virtual currencies, reward points, or any credit-based feature.
Your logo here โ Become a sponsor and get your logo featured in this README and on our website.
Interested in title sponsorship? Contact us at sponsors@climactic.co for premium placement and recognition.
You can install the package via composer:
composer require climactic/laravel-credits
Publish and run the migrations:
php artisan vendor:publish --tag="credits-migrations"
php artisan migrate
Optionally 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:
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:
'user.id', not 'user->id')" or ')// โ
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:
'value' key exists: uses three-parameter form whereMetadata($key, $operator, $value)'value' key missing: uses two-parameter form whereMetadata($key, $operator) where operator becomes the valueThis 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.
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:
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
EXPLAIN to identify slow queriesEvents 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 test
Please 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 security@climactic.co.
Laravel Credits is free and open source, built and maintained with care. If this package has saved you development time or helped power your application, please consider supporting its continued development.
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.
How can I help you explore Laravel packages today?