shubhamrai/laravel-user-discounts

Reusable user-level discount package for Laravel 12: deterministic stacking, per-user usage caps, idempotent and concurrency-safe application.

Maintainers

Package info

bitbucket.org/teastworkspace/laravel-user-discounts

pkg:composer/shubhamrai/laravel-user-discounts

Transparency log

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

1.0.0 2026-08-15 14:18 UTC

This package is not auto-updated.

Last update: 2026-09-01 20:03:21 UTC


README

shubhamrai/laravel-user-discountsv1.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

  1. What it does
  2. Requirements
  3. Installation
  4. Configuration
  5. Migrations
  6. Usageassign · revoke · eligibleFor · apply
  7. Stacking, caps and rounding
  8. Idempotency
  9. Concurrency
  10. Events
  11. Audit trail
  12. Architecture
  13. Database design
  14. Testing
  15. Versioning

What it does

An application that hands out discounts has to answer four awkward questions:

QuestionAnswer 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

PHP8.2+ (developed and tested on 8.3)
Laravel12.x
DatabaseMySQL 8.0+ / MariaDB 10.6+ with InnoDB
Extensionspdo_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 KEY and silently discards it, ignores START 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_at cleared), not recreated.
  • usage_count is preserved across revoke → reassign. Resetting it would let anyone bypass max_uses_per_user by revoking and re-assigning.
  • Writes an assigned audit row and dispatches DiscountAssigned only when something actually changed.
  • Throws DiscountNotFoundException for 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. Use eligibleFor() to render a UI; use apply() 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_scope and not discount_id directly? The natural constraint is UNIQUE(user_id, idempotency_key, discount_id), but the claim row has discount_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_scope is the NOT NULL projection COALESCE(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;
});
EventCarriesDispatched when
DiscountAssigneduserId, discount, assignment, reinstatedNew assignment or reinstatement
DiscountRevokeduserId, discount, assignmentA live assignment is revoked
DiscountApplieduserId, result, idempotencyKeyAn 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

ColumnTypeNotes
idbigint PK
codevarchar(100) unique
namevarchar(255)
typevarchar(32)percentage | fixednot an ENUM
valuedecimal(15,4)supports fractional percentages (33.3333%)
priorityint unsignedprimary stacking key
max_uses_per_userint unsigned nullnull = unlimited, 0 = never usable
starts_at / expires_atdatetime nullnull = open-ended, boundaries inclusive
is_activeboolean

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

ColumnTypeNotes
user_idbigint unsigneddeliberately not a foreign key
discount_idFK → discountscascade on delete
usage_countint unsignedonly ever mutated under FOR UPDATE
assigned_at / revoked_atdatetimesoft 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

ColumnNotes
user_id
discount_idFK → discounts, SET NULL on delete
user_discount_idFK → user_discounts, SET NULL on delete
actionassigned | revoked | applied
amount_before / discount_amount / amount_afterdecimal(15,2), null for assign/revoke
idempotency_keynull for assign/revoke — which exempts them from the unique index
idempotency_scope0 = operation claim, otherwise discount_id
metadatajson

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)
SuiteBackingCovers
Unit (62)none — no DB, no frameworkcalculator arithmetic, stacking order, usage-cap predicate
Feature (103)Testbench + SQLite in memoryprovider, migrations, assign/revoke/eligible/apply, audits, events, idempotency, rollback
Concurrency (16)real MySQL/InnoDB, real processesInnoDB 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_fork is unavailable on Windows, so workers are spawned with proc_open rather 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.