NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Shops for Laravel

Coupons & discounts

Coupons are powered entirely by coupons-for-laravel — shops stores no coupons of its own. Create coupons with that package, then reference them by code:

use RoundlyConsulting\Coupons\DataTransferObjects\CreateCouponData;
use RoundlyConsulting\Coupons\Enums\DiscountType;
use RoundlyConsulting\Coupons\Facades\Coupons;
use RoundlyConsulting\Money\Money;

// Percentage values are basis points: 1000 = 10 %
Coupons::generate(DiscountType::Percentage, 1000, 'WELCOME10')->activate()->save();

// Or build them with the coupons DTO:
Coupons::create(CreateCouponData::fixed(Money::ofMinor(500, 'EUR'), 'FIVEOFF'))->activate()->save(); // locked to EUR
Coupons::create(CreateCouponData::freeShipping('SHIPFREE'))->activate()->save();

Percentages are basis points. Fixed coupons, and any coupon with a minimum spend or cap, are locked to one currency — against a cart or order in another currency they resolve to no discount. New coupons are inactive until activated.

Previewing a code

Shops::coupons()->preview() resolves a code through the bound DiscountResolver — no redemption, no buyer (per-customer limits are only enforced at place-order). An unknown or currently non-redeemable code previews as a zero discount:

use RoundlyConsulting\Money\Money;
use RoundlyConsulting\Shops\Facades\Shops;

$result = Shops::coupons()->preview('WELCOME10', Money::ofMinor(1000, 'EUR'));

$result->discount;        // Money — 1.00 EUR off
$result->freeShipping;    // bool
$result->code;            // "WELCOME10"
$result->found;           // a coupon with that code exists
$result->hasDiscount();   // the discount is positive
$result->source;          // the coupon as a money Discount, when it applies

if ($result->found && ! $result->hasDiscount()) {
    // exists but not applicable now: inactive, expired, used up, below minimum spend, other currency
}

Cart prices

A cart prices with the code you pass, else its stored coupon_code:

$cart->update(['coupon_code' => 'WELCOME10']);
Shops::cart($cart)->price()->getFinalPrice();             // priced with the stored code

Shops::cart($cart)->price('SHIPFREE')->shippingCost();    // preview another code — nothing is redeemed

At place-order

The coupon is looked up, checked for the buyer and subtotal, its discount resolved, then redeemed once for the buyer under the coupon’s row lock (usage increments, per-customer caps apply, the coupons events fire). The discount is snapshotted onto the order and the coupon linked. Expiring, revoking or exhausting the coupon afterwards — including the single use this order consumed — never changes what the order costs; free-shipping coupons zero the shipping line:

$order->coupon;          // ?Coupon — the shops.discounts.coupon_model
$order->discount;        // ?Money — granted at placement, in the order currency
$order->free_shipping;   // bool
$order->coupon_code;     // ?string

A missing or non-redeemable code is silently skipped — including one the locked redemption refuses after a racing checkout took its last use.

Combining discounts

DiscountResult::source is the coupon as a money Discount, so you can stack it with your own discounts:

use RoundlyConsulting\Money\Discounts\DiscountStack;

$breakdown = DiscountStack::of($result->source, $loyaltyDiscount)->apply($subtotal, $shipping);

A custom resolver

use RoundlyConsulting\Money\Money;
use RoundlyConsulting\Shops\Contracts\DiscountResolver;
use RoundlyConsulting\Shops\Discounts\DiscountResult;

final class StaffDiscountResolver implements DiscountResolver
{
    public function resolve(string $code, Money $goods): DiscountResult
    {
        return $code === 'STAFF'
            ? new DiscountResult($goods->percentage(20), code: $code, found: true)
            : DiscountResult::none($goods->currency(), $code);
    }
}

// config/shops.php → 'discounts' => ['resolver' => StaffDiscountResolver::class, ...]

A custom resolver changes previews, cart prices and the discount snapshotted at place-order. Checkout still links and redeems coupons through coupons-for-laravel, so a code must exist there to be applied to an order. Swap the coupon model with discounts.coupon_model (a subclass of the coupons Coupon).

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 crypto

By 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.