Append-only points ledger
Record loyalty points as immutable ledger rows with a running balance, an idempotency key and a locked member row, so totals can always be audited.
LaravelDatabaseledgerloyaltypointsaudit
Code
<?php
namespace App\Loyalty;
use App\Models\LedgerEntry;
use App\Models\Member;
use Illuminate\Support\Facades\DB;
use RuntimeException;
final class PointsLedger
{
public static function balance(Member $member): int
{
return (int) LedgerEntry::where('member_id', $member->id)->latest('id')->value('balance_after');
}
/** Positive amount earns, negative spends. Returns the existing entry for a repeated key. */
public static function post(Member $member, int $amount, string $reason, string $key): LedgerEntry
{
return DB::transaction(function () use ($member, $amount, $reason, $key) {
// Serialises concurrent postings for this member only.
Member::whereKey($member->id)->lockForUpdate()->first();
if ($existing = LedgerEntry::where('idempotency_key', $key)->first()) {
return $existing;
}
$balance = self::balance($member) + $amount;
if ($balance < 0) {
throw new RuntimeException('Not enough points.');
}
return LedgerEntry::create([
'member_id' => $member->id,
'amount' => $amount,
'balance_after' => $balance,
'reason' => $reason,
'idempotency_key' => $key,
]);
});
}
}
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use LogicException;
class LedgerEntry extends Model
{
public const UPDATED_AT = null; // rows are written once
protected $guarded = [];
protected static function booted(): void
{
// History is append-only: fix mistakes with a reversing entry instead.
static::updating(fn () => throw new LogicException('Ledger entries are immutable.'));
static::deleting(fn () => throw new LogicException('Ledger entries cannot be deleted.'));
}
}
How it works
What it solves
A points integer on the member row is easy to write and impossible to audit: when a customer says "I should have 400 points", nobody can show how it got to 350. A ledger records every change as a new row, so the balance is the sum of history and any dispute can be replayed line by line.
How it works
LedgerEntry forbids change. Its updating and deleting model events throw, which stops accidents in application code (database permissions should back this up in production). Corrections are made by appending a reversing entry with a reason.
PointsLedger::post runs inside a transaction and takes lockForUpdate on the member row. That serialises concurrent posts for one member while leaving everybody else untouched. It reads the latest balance_after, refuses a debit that would go negative, and inserts the new row with the new running balance, so reading a balance is a single indexed lookup instead of a SUM.
An idempotency key with a unique index makes a retried request return the original entry rather than posting twice. This matters when a till app resends after a timeout.
Adapting it
Add expiry by posting scheduled negative entries, store a polymorphic source (order, referral, manual) for reporting, and keep a nightly check that each member's last balance equals the sum of their amounts.
Pitfalls
- Always post through the service, never
LedgerEntry::createelsewhere. - Use integers; do not store fractional points.
How to use it
- Create
ledger_entries(member_id, amount signed int, balance_after, reason, idempotency_key unique, created_at). - Copy the model and service.
- Earn:
PointsLedger::post($member, 120, 'purchase', "order-{$order->id}"). - Spend:
PointsLedger::post($member, -500, 'reward', "redeem-{$redemption->id}")(throws on shortfall). - Read balance:
PointsLedger::balance($member).
Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.