shubhamrai / laravel-user-discounts
Reusable user-level discount package for Laravel 12: deterministic stacking, per-user usage caps, idempotent and concurrency-safe application.
Package info
bitbucket.org/teastworkspace/laravel-user-discounts
pkg:composer/shubhamrai/laravel-user-discounts
Requires
- php: ^8.2
- illuminate/config: ^12.0
- illuminate/container: ^12.0
- illuminate/contracts: ^12.0
- illuminate/database: ^12.0
- illuminate/events: ^12.0
- illuminate/support: ^12.0
Requires (Dev)
- fakerphp/faker: ^1.23
- mockery/mockery: ^1.6
- orchestra/testbench: ^10.0
- phpunit/phpunit: ^11.5
Suggests
- ext-pdo_mysql: Required for the InnoDB row-locking guarantees and the multi-process concurrency test suite.
This package is not auto-updated.
Last update: 2026-09-01 20:03:21 UTC
README
shubhamrai/laravel-user-discounts — v1.0.0
A reusable Laravel 12 package for user-level discounts, built around four properties that are hard to retrofit: deterministic stacking, idempotent application, enforced per-user usage caps, and concurrency safety backed by the database rather than by hope.
Table of contents
- What it does
- Requirements
- Installation
- Configuration
- Migrations
- Usage — assign · revoke · eligibleFor · apply
- Stacking, caps and rounding
- Idempotency
- Concurrency
- Events
- Audit trail
- Architecture
- Database design
- Testing
- Versioning
What it does
An application that hands out discounts has to answer four awkward questions:
| Question | Answer here |
|---|---|
| Two discounts apply — in what order, and against what base? | Sequential stacking in a total, configured order |
| The checkout request was retried — do they get charged twice? | Idempotency keys enforced by a unique index |
| "One per customer" — how do you actually guarantee it? | SELECT … FOR UPDATE + a conditional UPDATE |
| A month later, why was this customer charged £720? | An append-only audit trail that reconstructs the whole operation |
use ShubhamRai\UserDiscounts\Facades\UserDiscounts;
UserDiscounts::assign($userId, 'WELCOME10');
UserDiscounts::assign($userId, 'LOYALTY20');
$result = UserDiscounts::apply($userId, 1000.00, idempotencyKey: 'ORDER-123');
$result->originalAmount; // 1000.00
$result->discountAmount; // 280.00
$result->finalAmount; // 720.00 (1000 → 900 → 720, not 700)
Requirements
| PHP | 8.2+ (developed and tested on 8.3) |
| Laravel | 12.x |
| Database | MySQL 8.0+ / MariaDB 10.6+ with InnoDB |
| Extensions | pdo_mysql |
InnoDB is a hard requirement, not a preference. The package's guarantees are InnoDB guarantees: transactions, row-level locking, duplicate-key waits and foreign keys. MyISAM parses
FOREIGN KEYand silently discards it, ignoresSTART TRANSACTION, and takes no row locks — so on MyISAM everything would appear to work while every concurrency guarantee quietly evaporated.This is not hypothetical: many local stacks (WAMP, XAMPP) ship with
default_storage_engine=MyISAM. The migrations therefore name the engine explicitly instead of inheriting it, and a test asserts it.
Verify at any time:
SHOW TABLE STATUS WHERE Name IN ('discounts','user_discounts','discount_audits');
-- Engine must read InnoDB for all three.
SQLite is supported for the fast feature-test suite only. It has no row-level locking and must not be used in production with this package.
Installation
From Packagist
composer require shubhamrai/laravel-user-discounts
From a local path (monorepo)
{
"repositories": [
{ "type": "path", "url": "packages/user-discounts" }
],
"require": {
"shubhamrai/laravel-user-discounts": "^1.0"
}
}
composer update shubhamrai/laravel-user-discounts
The service provider and the UserDiscounts facade alias are registered through
Laravel package auto-discovery — no manual wiring in bootstrap/providers.php.
Confirm:
php artisan package:discover
# shubhamrai/laravel-user-discounts .......... DONE
Configuration
Publishing is optional; sensible defaults are merged automatically.
php artisan vendor:publish --tag=user-discounts-config
config/discounts.php:
return [
// Informational only. The package keys everything on a plain int user id
// and never requires this class to exist.
'user_model' => env('USER_DISCOUNTS_USER_MODEL', 'App\Models\User'),
'user_table' => env('USER_DISCOUNTS_USER_TABLE', 'users'),
// Stacking is sequential, so ORDER IS PART OF THE ARITHMETIC.
// `id ASC` is always appended, so the ordering is guaranteed total.
'stacking' => [
'order' => 'priority_asc', // priority | value | created_at | id, _asc/_desc
'tie_breaker' => 'id_asc',
],
// Ceiling on the SUM OF NOMINAL RATES of percentage discounts in one apply().
// null disables it. Fixed-amount discounts are not constrained by it.
'max_percentage' => 50.0,
// Applied after EVERY step, so the audit trail reconciles line by line.
'rounding' => [
'precision' => 2,
'mode' => PHP_ROUND_HALF_UP, // or 'half_up' | 'half_down' | 'half_even' | 'half_odd'
],
'types' => ['percentage', 'fixed'],
];
order also accepts an array for multi-level sorting:
'order' => ['priority_asc', 'value_desc'].
Unknown column names are dropped, never interpolated into SQL — config is not a trusted source of identifiers.
Migrations
php artisan migrate
The migrations are registered by the provider, so they run without publishing.
Publish them only if you need to edit them (for example to add your own foreign
key from user_discounts.user_id to your users table):
php artisan vendor:publish --tag=user-discounts-migrations
They must run in dependency order, which their timestamps enforce:
2026_08_13_180911_create_discounts_table discounts
2026_08_13_181105_create_user_discounts_table ↳ user_discounts (FK → discounts)
2026_08_13_181200_create_discount_audits_table ↳ discount_audits (FK → discounts, user_discounts)
Usage
Every method is available on the facade, or by injecting the
ShubhamRai\UserDiscounts\Contracts\DiscountManager contract — prefer the
contract in application code so it can be decorated or faked.
use ShubhamRai\UserDiscounts\Contracts\DiscountManager;
public function __construct(private readonly DiscountManager $discounts) {}
assign
assign(int $userId, Discount|string|int $discount): UserDiscount
UserDiscounts::assign(1, 'WELCOME10'); // by code
UserDiscounts::assign(1, $discount); // by model
UserDiscounts::assign(1, 42); // by id
- Idempotent. Calling it twice produces one row — guaranteed by
UNIQUE(user_id, discount_id), not by a read-then-write. - A revoked assignment is reinstated (
revoked_atcleared), not recreated. usage_countis preserved across revoke → reassign. Resetting it would let anyone bypassmax_uses_per_userby revoking and re-assigning.- Writes an
assignedaudit row and dispatchesDiscountAssignedonly when something actually changed. - Throws
DiscountNotFoundExceptionfor an unknown code or id.
revoke
revoke(int $userId, Discount|string|int $discount): bool
UserDiscounts::revoke(1, 'WELCOME10'); // true — was live, now revoked
UserDiscounts::revoke(1, 'WELCOME10'); // false — already revoked, no-op
Sets revoked_at; never deletes. History, usage counts and audits survive,
and the assignment can be reinstated. Returns false (writing nothing and
dispatching nothing) when there was nothing live to revoke.
eligibleFor
eligibleFor(int $userId): Collection // of UserDiscount, each with ->discount loaded
foreach (UserDiscounts::eligibleFor(1) as $assignment) {
echo $assignment->discount->code, ' ', $assignment->usage_count;
}
Excludes inactive, not-yet-started, expired, revoked and cap-exhausted discounts, ordered deterministically per config.
⚠️
eligibleFor()is a read, not a reservation. It takes no locks and its answer can be stale the instant it returns. Never use it to decide whether a discount may be consumed —apply()re-derives eligibility for itself under row locks. UseeligibleFor()to render a UI; useapply()to charge.
For a no-side-effect price preview, use preview():
UserDiscounts::preview(1, 1000.00)->finalAmount; // no locks, no writes, no events
apply
apply(
int $userId,
float|int|string $amount,
?string $idempotencyKey = null,
array $metadata = [],
): DiscountResult
$result = UserDiscounts::apply(
userId: 1,
amount: '1000.00',
idempotencyKey: "ORDER-{$order->id}",
metadata: ['order_id' => $order->id, 'channel' => 'web'],
);
$result->originalAmount; // 1000.0
$result->discountAmount; // 280.0
$result->finalAmount; // 720.0
$result->replayed; // false on the first call, true on a duplicate
$result->applications; // DiscountApplication[] — one per step of the stack
foreach ($result->applications as $step) {
$step->code(); // 'WELCOME10'
$step->amountBefore; // 1000.0
$step->discountAmount; // 100.0
$step->amountAfter; // 900.0
$step->effectiveValue; // 10.0 — the rate ACTUALLY charged after capping
}
Transaction flow
BEGIN
├─ insert idempotency claim row ← unique index arbitrates same-key races
├─ SELECT … FOR UPDATE ← serialises different-key races
├─ re-derive eligibility under locks ← eligibleFor()'s answer is not trusted
├─ calculate (pure, no persistence)
├─ conditional UPDATE usage_count ← cap re-asserted in the WHERE clause
├─ insert one audit line per applied discount
└─ finalise the claim row with the operation totals
COMMIT
└─ dispatch DiscountApplied ← after commit, never inside
Any failure rolls back everything: usage counters are never partially incremented, and no audit survives an aborted apply.
Stacking, caps and rounding
Stacking is sequential. Each discount applies to what the previous one left:
amount 1000, then 10%, then 20%
1000 ──10%──▶ 900 ──20%──▶ 720 ✓ correct
1000 ──30% of the original──▶ 700 ✗ wrong
Because order changes the arithmetic, it must be total and deterministic. The
default is priority ASC, id ASC; id ASC is always appended so no two rows are
ever incomparable and the database is never left to pick a winner.
The percentage cap limits the sum of nominal rates in one operation. With
max_percentage = 50, a 30% and a 40% discount apply as 30% then 20%. Note
that this removes 44% of the original amount, not 50% — a consequence of
sequential stacking, and the standard reading of "maximum cumulative percentage".
Fixed-amount discounts are not constrained by it.
Rounding is applied after every step, not just at the end, so each audit row
satisfies amount_before − discount_amount = amount_after exactly and the lines
sum to the operation total. Rounding only at the end would leave an audit trail
that does not add up.
Guarantees
- The final amount can never go negative — a fixed discount is capped at what remains.
- A discount that would contribute nothing — cap exhausted, amount already zero,
or a value that rounds away to
0.00— is skipped, not applied at zero, so it never burns one of a limited-use discount's slots.
Idempotency
Pass an idempotency key and apply() becomes safe to retry:
UserDiscounts::apply(1, 1000, 'ORDER-123'); // computes, consumes one use
UserDiscounts::apply(1, 1000, 'ORDER-123'); // replays — no usage, no event
UserDiscounts::apply(1, 1000, 'ORDER-123'); // replays
A replay returns the original recorded amounts, reconstructed from the audit trail — not a fresh calculation. It stays correct even if rates changed, the discount was deactivated, or the discount was deleted entirely.
$second = UserDiscounts::apply(1, 5000, 'ORDER-123');
$second->replayed; // true
$second->originalAmount; // 1000.0 ← the recorded amount, not the 5000 passed in
How it is enforced. A SELECT … WHERE idempotency_key = ? followed by an
INSERT is race-prone — two requests can both pass the check. Instead the
database arbitrates:
UNIQUE (user_id, idempotency_key, idempotency_scope)
Each apply() writes an operation claim row (idempotency_scope = 0) plus
one line row per applied discount (idempotency_scope = discount_id). The
claim row is inserted first, before any work. A competing transaction's INSERT
blocks on the duplicate-key lock until the winner commits or rolls back — a
real mutex, not a check.
Why
idempotency_scopeand notdiscount_iddirectly? The natural constraint isUNIQUE(user_id, idempotency_key, discount_id), but the claim row hasdiscount_id IS NULL, and MySQL treats NULLs as distinct inside a unique index. A nullable column therefore cannot block a duplicate claim — precisely the row we need arbitrated.idempotency_scopeis theNOT NULLprojectionCOALESCE(discount_id, 0), which makes the constraint actually bite. It is the equivalent-but-working form of that constraint.
Consequences worth knowing:
- Keys are scoped per user. The same key for two users is two operations.
- Different keys still respect usage caps. Varying the key is not a way
around
max_uses_per_user— the row locks handle that independently. - A rollback frees the key, so a transient failure does not permanently poison an order id.
- An operation that applied nothing is still idempotent — that is what the claim row is for.
- Without a key, each call consumes usage. That is a deliberate opt-out.
Concurrency
The failure mode this package exists to prevent:
max_uses_per_user = 1
Request A: SELECT usage_count → 0 ─┐
Request B: SELECT usage_count → 0 ─┤ both read 0
Request A: UPDATE → 1 │
Request B: UPDATE → 1 ┘ both "succeed", cap breached
Three layers, in order of who does the work:
1. Row-level locks (the primary mechanism). apply() opens a transaction and
takes SELECT … FOR UPDATE on every live assignment for the user, ordered by
primary key. Consistent lock-acquisition ordering across all transactions is
what prevents deadlocks between users with overlapping discount sets. A locking
read always sees the latest committed data, so the usage_count values are
authoritative even under REPEATABLE READ.
2. A conditional UPDATE (defence in depth).
UPDATE user_discounts
SET usage_count = usage_count + 1
WHERE id = ? AND revoked_at IS NULL AND usage_count < ?
The increment is computed by the database — never a read-modify-write in PHP —
and the cap is re-asserted in the WHERE clause. Zero rows affected throws and
rolls the transaction back. Under correct locking this is unreachable; it exists
so that if the lock were ever lost (a MyISAM table, a future refactor dropping
the FOR UPDATE) the result is a loud failure, not a silently breached cap.
3. Unique constraints (the final word). UNIQUE(user_id, discount_id) for
assignment; the idempotency index above for application. These hold even against
a client that bypasses the package entirely.
No sleeps, no spin-retries, no application-level advisory locks. The bounded
retry in apply() handles exactly one situation — the key's owner rolled back,
freeing it — and is not what makes the code correct.
Proving it
tests/Concurrency/ConcurrentApplyTest.php spawns 8 real OS processes, each
with its own PHP runtime, connection and transaction, released at a single shared
wall-clock instant so their transactions genuinely overlap inside MySQL. Calling
apply() twice in a row would prove nothing — that is sequential execution, and
it passes even with no locking at all.
The suite also verifies its own overlap: if the workers drifted apart and ran one after another, the test fails rather than passing for the wrong reason.
The tests were validated by deliberately removing the locking. Result:
usage_count is 7 after 8 concurrent applies of a max_uses_per_user=1 discount.
With the locking restored: exactly 1, every run.
Events
use ShubhamRai\UserDiscounts\Events\{DiscountAssigned, DiscountRevoked, DiscountApplied};
Event::listen(DiscountApplied::class, function (DiscountApplied $event) {
$event->userId;
$event->idempotencyKey;
$event->result; // full DiscountResult, no re-query needed
$event->result->finalAmount;
});
| Event | Carries | Dispatched when |
|---|---|---|
DiscountAssigned | userId, discount, assignment, reinstated | New assignment or reinstatement |
DiscountRevoked | userId, discount, assignment | A live assignment is revoked |
DiscountApplied | userId, result, idempotencyKey | An apply committed with ≥1 discount |
Events are announcements, never business logic, and are dispatched after the transaction returns — never inside it. A listener firing mid-transaction would observe uncommitted state and could roll the caller's work back by throwing. So:
- a rolled-back apply dispatches nothing;
- a replayed apply dispatches nothing;
- a no-op assign/revoke dispatches nothing.
Audit trail
Every operation is recorded in discount_audits with enough detail to
reconstruct exactly what happened.
use ShubhamRai\UserDiscounts\Models\DiscountAudit;
DiscountAudit::query()->idempotencyKey($userId, 'ORDER-123')->get();
An applied operation writes a claim row (discount_id IS NULL) holding the
operation totals, plus one line row per applied discount holding that step's
amount_before, discount_amount, amount_after, and metadata including the
effective rate actually charged — a 30% discount throttled to 20% by the cap
records 20.0, because an audit trail should say what happened, not what was
requested.
The foreign keys use ON DELETE SET NULL, not cascade. An audit trail whose
rows are deleted when their subject is deleted is not an audit trail; deleting a
discount must not erase the record that it was applied.
Architecture
Facades\UserDiscounts ─resolves─▶ Contracts\DiscountManager
│
Services\DiscountService
ORCHESTRATION ONLY
transactions · locks · usage · audits · events · idempotency
│
┌───────────────────────────────┼───────────────────────────────┐
▼ ▼ ▼
Services\DiscountCalculator Support\StackingOrder Support\DiscountEligibility
PURE arithmetic total ordering the eligibility predicate
no DB, no events, no clock SQL + PHP from one spec SQL + PHP from one spec
│
▼
Data\DiscountResult ─▶ Data\DiscountApplication (immutable value objects)
Why the calculator is separate from the service. The calculator is a pure
function of (amount, ordered discounts). That single decision buys a lot:
- the whole stacking/cap/rounding matrix is unit-tested with unsaved models, no database, no framework boot — 62 unit tests run in ~1.5s;
- the concurrency-sensitive code contains no money arithmetic to get wrong;
- the arithmetic contains no locking to get wrong;
- results are reproducible — same input, same output, always.
Why StackingOrder and DiscountEligibility exist. Each rule is needed in
two forms: SQL for eligibleFor(), and PHP for apply() (which sorts and filters
rows it already holds locks on, and must not re-query). Two hand-written copies
would drift — a boundary handled one way in a WHERE clause and another way in a
comparison. Each class emits both forms from one definition, and
EligibilityParityTest asserts they agree across the full fixture matrix.
Database design
discounts
| Column | Type | Notes |
|---|---|---|
id | bigint PK | |
code | varchar(100) unique | |
name | varchar(255) | |
type | varchar(32) | percentage | fixed — not an ENUM |
value | decimal(15,4) | supports fractional percentages (33.3333%) |
priority | int unsigned | primary stacking key |
max_uses_per_user | int unsigned null | null = unlimited, 0 = never usable |
starts_at / expires_at | datetime null | null = open-ended, boundaries inclusive |
is_active | boolean |
type is a VARCHAR, not an ENUM, so that config('discounts.types') is the
single source of truth — an ENUM would mean a consuming application could not add
a type without a migration.
DATETIME, not TIMESTAMP: TIMESTAMP is capped at 2038, and when
explicit_defaults_for_timestamp is OFF, MySQL silently attaches
DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP to the first NOT NULL
TIMESTAMP column — which would have made assigned_at bump itself on every
usage increment.
user_discounts
| Column | Type | Notes |
|---|---|---|
user_id | bigint unsigned | deliberately not a foreign key |
discount_id | FK → discounts | cascade on delete |
usage_count | int unsigned | only ever mutated under FOR UPDATE |
assigned_at / revoked_at | datetime | soft revocation |
UNIQUE(user_id, discount_id) · INDEX(user_id, revoked_at)
Why user_id is not a foreign key. A reusable package must not assume the
consuming application keeps users in a table called users, keys them with a
BIGINT, or even stores them in the same database — users may live behind an API
or in another service. Constraining to a users table would make the package
unusable in exactly the architectures most likely to need it. Applications that
do want the constraint can publish the migrations and add it.
Why revoked_at instead of deleting. Deleting destroys the usage history,
which would let anyone reset a consumed cap by revoking and re-assigning; it
orphans the audit trail; and it makes "was this user ever entitled to this?"
unanswerable. Revocation is a state change, not an absence of data.
discount_audits
| Column | Notes |
|---|---|
user_id | |
discount_id | FK → discounts, SET NULL on delete |
user_discount_id | FK → user_discounts, SET NULL on delete |
action | assigned | revoked | applied |
amount_before / discount_amount / amount_after | decimal(15,2), null for assign/revoke |
idempotency_key | null for assign/revoke — which exempts them from the unique index |
idempotency_scope | 0 = operation claim, otherwise discount_id |
metadata | json |
UNIQUE(user_id, idempotency_key, idempotency_scope) — see
Idempotency for why this shape.
Testing
The package is independently testable:
cd packages/user-discounts
composer install
vendor/bin/phpunit
OK (181 tests, 663 assertions)
| Suite | Backing | Covers |
|---|---|---|
Unit (62) | none — no DB, no framework | calculator arithmetic, stacking order, usage-cap predicate |
Feature (103) | Testbench + SQLite in memory | provider, migrations, assign/revoke/eligible/apply, audits, events, idempotency, rollback |
Concurrency (16) | real MySQL/InnoDB, real processes | InnoDB engine, FKs, indexes, multi-process races |
composer test # everything
composer test:unit # fastest, no database
composer test:feature
composer test:concurrency # needs MySQL
The concurrency suite creates and drops its own database. Point it anywhere via
phpunit.xml:
<env name="USER_DISCOUNTS_MYSQL" value="1"/>
<env name="USER_DISCOUNTS_MYSQL_HOST" value="127.0.0.1"/>
<env name="USER_DISCOUNTS_MYSQL_PORT" value="3306"/>
<env name="USER_DISCOUNTS_MYSQL_DATABASE" value="user_discounts_pkg_test"/>
<env name="USER_DISCOUNTS_MYSQL_USERNAME" value="root"/>
<env name="USER_DISCOUNTS_MYSQL_PASSWORD" value=""/>
Without a reachable MySQL it skips with an explicit message rather than
silently disappearing from the run. Set USER_DISCOUNTS_MYSQL=0 to disable.
The package's suites are also registered in the host application's root
phpunit.xml, so php artisan test covers them too. Tests that exist but are
never discovered are worse than no tests at all.
Known environment limitations
- SQLite cannot verify concurrency. It has no row-level locking. The feature suite uses it for speed and portability only; every concurrency claim is verified against real MySQL/InnoDB.
pcntl_forkis unavailable on Windows, so workers are spawned withproc_openrather than forked. Same process isolation, slightly higher start-up cost, absorbed by the shared-start barrier.- A race is probabilistic. One passing run is weaker evidence than one failing run. The suite therefore uses a high process count, asserts exact final state rather than a tolerance, and verifies the overlap actually occurred.
Versioning
Semantic Versioning. Current release: 1.0.0.
git tag -a v1.0.0 -m "Initial release"
git push origin v1.0.0
Within 1.x: no breaking changes to DiscountManager, the migration schema, or
the config keys. Breaking changes ship in 2.0.0. See CHANGELOG.md.
License
MIT.