Skip to main content
Lunar applies discounts to a cart through a registry of discount type classes, each reading its own configuration from the discount’s data column.

Overview

A Lunar\Core\Models\Discount record describes when a discount is active, who it is available to, and what it targets. The type column names a class implementing Lunar\Core\Contracts\DiscountType that decides how the discount is applied to a cart. Lunar ships three types: percentage off, fixed amount off, and buy-x-get-y.
coupon is cast through Lunar\Core\Casts\CouponString, which normalizes the stored value to uppercase.

Relationships

Scopes

The status attribute

Targeting: Discountable

Lunar\Core\Models\Discountable links a product, product variant, or collection to a discount, tagged with a role via its type column.
  • condition: must be in the cart for the discount to activate (used by buy-x-get-y for the “buy” side).
  • exclusion: excluded from a percentage-off or fixed-amount-off discount’s eligible lines.
  • limitation: a percentage-off or fixed-amount-off discount only applies to these products or variants.
  • reward: given as the reward by a buy-x-get-y discount.

Relationships

Scopes

Percentage-off and fixed-amount-off discounts also target lines by collection or brand directly through the Discount::collections() and Discount::brands() relationships (pivot type of limitation or exclusion), rather than through Discountable.

Built-in discount types

Every discount type extends Lunar\Core\DiscountTypes\AbstractDiscountType and implements Lunar\Core\Contracts\DiscountType:
AbstractDiscountType provides shared behavior every type relies on:
  • checkDiscountConditions(Cart $cart): bool: validates the discount against the cart before it applies anything, including a matching coupon code (if the discount requires one), the cart customer against customers() (if restricted), a data.min_prices minimum spend for the cart’s currency, max_uses, and max_uses_per_user. Every shipped type calls this at the top of apply().
  • markAsUsed(Cart $cart): increments uses and attaches the cart’s user.
  • addDiscountBreakdown(Cart $cart, DiscountBreakdown $breakdown): records how much this discount took off, and which lines it affected, on $cart->discountBreakdown.
data.min_prices is keyed by currency code and stored in minor units, and applies regardless of which type is used:

PercentageOff

Applies a percentage reduction to each eligible cart line.

FixedAmountOff

Deducts a fixed, per-currency amount from the cart, distributed proportionally across eligible lines using the price calculator’s largest-remainder allocation (so the total discount always exactly matches the configured amount).
PercentageOff and FixedAmountOff both narrow the cart’s eligible lines through Lunar\Core\DiscountTypes\Concerns\TargetsCartLines, which applies the discount’s collection, brand, and Discountable limitations and exclusions before any amount is calculated.
Prior to v2, this was a single AmountOff type switched by a data.fixed_value boolean. Every stored discount of that type is migrated by the upgrade package to either PercentageOff or FixedAmountOff, and data.fixed_values is renamed to data.amounts.

BuyXGetY

“Buy X, get Y free” promotions. Condition products (the “buy” side) come from discountableConditions, reward products (the “get” side) from discountableRewards. Both accept products, product variants, or collections.
Condition and reward matching also accepts collections: a Discountable entry with discountable_type set to a collection matches any cart line whose product belongs to that collection.

Automatically adding rewards

When automatically_add_rewards is true and a qualifying cart does not already contain enough reward items, BuyXGetY adds a cart line for the reward rather than requiring the customer to add it themselves:
  • A reward candidate is chosen at random from the fulfillable discountableRewards (a collection reward picks a random fulfillable product from that collection).
  • Only reward items that can currently be fulfilled at the required quantity are considered; an out-of-stock reward is skipped rather than raising an error.
  • A reward quantity greater than one is added to a single cart line, not one line per unit.
  • Added lines are tracked in the line’s meta.added_by_discount for the discount’s ID and quantity.

The Discounts facade

Lunar\Core\Facades\Discounts resolves Lunar\Core\Contracts\DiscountManager (bound scoped, so a long-lived worker gets a fresh instance per request or job):
getDiscounts() and apply() memoize the resolved discount list per cart state (cart ID, coupon code, customer, and line purchasables). Call resetDiscounts() after a change that should re-evaluate eligibility, for example after a coupon code is applied to the cart.

Registering a custom discount type

Typically called from a service provider’s boot() method. See Extending Discounts for how to build a custom discount type.

Validating coupons

The default validator, Lunar\Core\Validation\CouponValidator, checks that the coupon matches an active discount under its max_uses and, if the discount sets max_uses_per_user, that the authenticated user has not exceeded it. Bind a different implementation of Lunar\Core\Contracts\CouponValidator in your own service provider to customize this.

How discounts apply in the cart pipeline

Eligibility is narrowed in two stages. First, getDiscounts() pre-filters candidate discounts at the database level: when a cart is given, only discounts whose Discountable entries (condition or limitation) match the cart’s products or variants, or whose collections()/brands() pivot type is condition and matches a collection or brand on a cart line, are loaded at all. Second, each loaded discount’s own apply() re-checks the full set of conditions (checkDiscountConditions()) and, for PercentageOff/FixedAmountOff, narrows to eligible lines via TargetsCartLines (limitation/exclusion on collections, brands, and Discountable entries). Discounts are applied as one step in the cart calculation pipeline (lunar.cart.pipelines.calculate), after lines and shipping are calculated and before tax:
Lunar\Core\Pipelines\Cart\ApplyDiscounts resets $cart->discounts and $cart->discountBreakdown, then calls Discounts::apply($cart), which runs each eligible discount’s type in turn. Each type mutates the affected CartLine totals (discountTotal, subTotalDiscounted) directly and, if it applied, records a Lunar\Core\ValueObjects\Cart\DiscountBreakdown describing what happened:
Because tax is calculated after discounts, discount amounts reduce the tax-relevant subtotal for the affected lines.

Custom discount types

A custom discount type extends Lunar\Core\DiscountTypes\AbstractDiscountType:
This is a brief summary. See Extending Discounts for the full guide, including how to target cart lines, build a DiscountBreakdown, and register the type.