binafy/laravel-discount
Laravel Discount lets you apply percentage, fixed amount, buy X get Y, tiered, and free shipping discounts with codes, expiry dates, usage limits, and stackable rules. Integrates with Laravel Cart.
Architecture fit. binafy/laravel-discount is a domain service, not a commerce platform. It ships plain Eloquent models over three tables (discounts, discount_usages, discountables), a facade with a deliberately small surface (apply, applyCode, applyMany, isValid, findByCode, generateCode(s), matchingTier), and a DiscountResult value object (originalAmount, discountAmount, payableAmount(), hasFreeShipping(), payableShipping(), payableTotal()). That is the right shape for a Laravel monolith whose checkout owns orders and payments: the package owns promotion definitions and evaluation, not the order lifecycle. It is a poor fit if checkout is a separate service — you would either re-expose the library over an API or reimplement the rule set elsewhere.
Integration feasibility. High. Auto-registered service provider, migrations run out of the box, optional publish of config (table names, user model, code alphabet) and migrations. The README is unusually complete for a 58-star package: per-type examples, documented exception types, an explicit upgrade note for the type enum widening, and edge-case illustrations (fixed discounts clamped at zero, tier boundaries, BXGY with mixed baskets). The result object is a clean seam for an internal adapter, which is where I would put the package behind our own interface on day one.
Technical risk.
60.0, 100.0) against decimal(10,2) storage. Rounding policy (half-up vs banker's), accumulation across stacked discounts, and penny-level reconciliation against the PSP/invoice are undocumented. Highest-severity unknown.redeem are described functionally, not transactionally. A popular code under concurrent checkout is a classic oversell; assume we must add locking or a unique redemption key until proven otherwise.buy_x_get_y, tiered, and free_shipping types, requiring existing installs to widen an enum and make value optional. Schema-affecting feature additions in a young package imply upgrade toil and migration rehearsal.Key questions.
redeem/usage-limit enforcement atomic (row lock, unique constraint, or idempotency key)? Can double-submit burn two usages?apply() the single validation path, or can constraints (min_order_value, windows, limits) be bypassed by constructing a Discount directly?starts_at/expires_at evaluated in app timezone or UTC, and is the boundary inclusive?discountables scope — global product eligibility, or per-user/segment? How are guest redemptions keyed (session, IP, email)?isValid() share code with apply(), or are there two divergent evaluators?Stack fit. Laravel 9+ / PHP 8.1+; confirm the tested matrix against our actual Laravel minor and DB driver before committing. The package is happiest alongside binafy/laravel-cart (same vendor). If we run a different cart, integration is still viable — we pass an amount and optional quantity, and consume DiscountResult — but we absorb any line-level allocation work ourselves. Free shipping is the one integration that is not self-contained: the flag is advisory, so the shipping calculator must be changed to honour it, with a test that fails if it doesn't.
Migration path. Pin an exact version in composer.json. Publish config and migrations into the application so the schema is versioned in our repo rather than implicitly by the vendor — this also fixes the "published migration stops loading the package copy" behaviour into something we control. Rehearse on an ephemeral database in CI, then staging, then production. If any discounts table already exists, ship the enum-widening migration first and add doctrine/dbal if we are on Laravel 10 or below. Backout is clean: drop three tables, remove the dependency, no unrecoverable data — provided promotions are not the sole source of truth for historical order pricing (they must not be; snapshot the applied discount onto the order record).
Compatibility. Verify ->change() behaviour per driver (MySQL 8 vs Postgres check-constraint representation of enum(), SQLite in tests). Store and evaluate timestamps in UTC with an explicit conversion at the display layer.
Sequencing.
DiscountPort plus our own DTO, map package exceptions (DiscountNotFoundException, DiscountExpiredException, DiscountNotStartedException) to domain errors at that boundary, and enforce an architecture rule that nothing outside the adapter references the facade.DiscountExpired.Gate the decision on the spike proving three things: deterministic rounding, atomic redemption, and a workable refund release path (built by us if necessary).
Maintenance. Expect schema and enum churn given the recent type additions; budget one to two days per upgrade for review, migration rehearsal, and regression on promotion math. Single-maintainer risk is real but partly mitigated by MIT licensing — a fork is a viable contingency if the project stalls, which is precisely why everything goes through our adapter. Add the package to the dependency-review list with a named owner.
Support. Community GitHub issues only; no SLA, no commercial escalation. We own L2/L3. That means one backend engineer as named owner, a written runbook (how to disable a live promotion, how to release a stuck usage count, how to reconcile a disputed order), and a "bad coupon" playbook that assumes minutes, not days.
Scaling. Evaluation is in-process — no network hop, a handful of queries, cheap at checkout volumes. The scaling hazards are writes, not reads: discount_usages inserts plus usage-count checks on a hot code create row contention, so index code, add a unique constraint keyed on (discount_id, order_id) for idempotent redemption, and cache read-only discount definitions with explicit invalidation on admin writes. Bulk code generation of tens of thousands should be a background job with a unique index and retry, never an HTTP request.
Failure modes.
0.0 when thresholds aren't met (below a tier, short of a full BXGY set) → no reason codes are returned, so the UX must explain why; build our own messaging.applyCode and log failures.Ramp-up. The README is sufficient for a mid-level Laravel developer to ship a percentage coupon in about a day; allow three to five days to be genuinely safe across tiered, BXGY, and free shipping, plus the adapter pattern. Codify a review checklist — money type and rounding, redemption idempotency, exception mapping, free-shipping propagation, timezone handling — so promotion work doesn't depend on one person's memory.
How can I help you explore Laravel packages today?