Discounts
Discount primitives for coupons, promotions and carts. A discount never removes more than its base and never produces a negative total:
use RoundlyConsulting\Money\Discounts\Discount;
$subtotal = Money::ofMajor('100', 'EUR');
Discount::percentage('12.5')->cappedAt(Money::ofMajor('20', 'EUR'))->applyTo($subtotal); // 87.50 EUR
Discount::percentage('12.5')->amountFor($subtotal); // 12.50 EUR
Discount::fixed(Money::ofMajor('150', 'EUR'))->applyTo($subtotal); // 0.00 EUR — never negative- Discount::fixed(Money) — a fixed amount off the subtotal.
- Discount::percentage($percent) — 0 to 100 %.
- Discount::freeShipping() — 100 % on the shipping target.
- ->cappedAt(Money), ->withPriority(int) (higher applies first), ->exclusive(), ->labelled('CODE'), ->onShipping() / ->onSubtotal().
- amountFor($base) — how much is removed (0 ≤ amount ≤ base, and ≤ the cap); applyTo($base) — the base minus that amount.
The target is metadata for stacks: amountFor() applies to whatever base it is given, so never applyTo() a free-shipping discount to the goods price — route by target().
Stacking
use RoundlyConsulting\Money\Discounts\Discount;
use RoundlyConsulting\Money\Discounts\DiscountStack;
use RoundlyConsulting\Money\Enums\StackingStrategy;
$subtotal = Money::ofMajor('100', 'EUR');
$shipping = Money::ofMajor('5', 'EUR');
$breakdown = DiscountStack::of(
Discount::percentage(10)->labelled('WELCOME10'),
Discount::fixed(Money::ofMajor('5', 'EUR'))->withPriority(10),
Discount::freeShipping(),
Discount::percentage(25)->exclusive(),
)->using(StackingStrategy::Sequential)
->capTotalAt(Money::ofMajor('30', 'EUR'))
->apply($subtotal, $shipping);
$breakdown->totalDiscount(); // 25.00 EUR — the exclusive 25 % wins
$breakdown->total(); // 80.00 EUR- Sequential (default) — each discount applies to the running remainder of its target, so percentages compound.
- Additive — each discount applies to the original base.
- BestOf — only the single largest discount applies.
- Exclusivity — the best exclusive discount competes with the whole non-exclusive set; the larger total wins, a tie goes to the non-exclusive set.
- Order — subtotal discounts by priority (descending) then insertion order, then shipping discounts.
- Each target is clamped to its base, then capTotalAt() applies; both trim in reverse application order.
In the example, the exclusive 25 % (25.00) beats the non-exclusive set (5.00 + 9.50 + 5.00 shipping = 19.50), so the breakdown holds one row. Without the exclusive discount, all three apply and the total is 85.50 EUR.
The breakdown
$breakdown->subtotal; // 100.00 EUR
$breakdown->subtotalDiscount; // Money
$breakdown->shipping; // 5.00 EUR (zero when none was passed)
$breakdown->shippingDiscount; // Money
$breakdown->discountedSubtotal();
$breakdown->discountedShipping();
foreach ($breakdown->applied as $row) { // list<AppliedDiscount>
$row->discount->label(); // e.g. "WELCOME10"
$row->target; // DiscountTarget::Subtotal or DiscountTarget::Shipping
$row->amount; // the Money removed
}The applied amounts always sum to subtotalDiscount + shippingDiscount, and neither exceeds its base. To spread an order-level discount over line items, use DiscountAllocator (see Allocation & splitting).
Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.