Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Discount Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and migrate — there is nothing else required to get a working setup:

composer require binafy/laravel-discount
php artisan migrate   # creates discounts, discount_usages, discountables

The service provider is auto-discovered. Publishing is optional; only publish the config when you need to change table names, the user model, or code-generation defaults:

php artisan vendor:publish --tag="laravel-discount-config"
php artisan vendor:publish --tag="laravel-discount-migrations"

Where to look first, in this order: config/laravel-discount.php (user model, table names, codes block), Binafy\LaravelDiscount\Enums\DiscountType (the canonical list of supported types), Binafy\LaravelDiscount\Models\Discount (a plain Eloquent model — no surprises), and the DiscountResult object returned by the facade (that is your contract with the rest of the app).

First use case — a percentage coupon at checkout. Create the discount, apply it by the code the customer typed, read the payable amount:

use Binafy\LaravelDiscount\Enums\DiscountType;
use Binafy\LaravelDiscount\Facades\LaravelDiscount;
use Binafy\LaravelDiscount\Models\Discount;

Discount::query()->create([
    'code'       => 'WELCOME10',
    'name'       => 'Welcome 10%',
    'type'       => DiscountType::Percentage,
    'value'      => 10,
    'starts_at'  => now(),
    'expires_at' => now()->addMonth(),
]);

$result = LaravelDiscount::applyCode('WELCOME10', $cartTotal, $user);

$result->originalAmount;   // 200.0
$result->discountAmount;   // 20.0
$result->payableAmount();  // 180.0

A discount with a code is a coupon; one without a code is automatic. That single distinction drives most of the design decisions below.

Implementation Patterns

Separate "quote" from "commit." Applying a discount is pure calculation — it validates and returns a DiscountResult, it does not redeem. Redemption (usage limits) is a separate call you make after payment succeeds. Structure checkout as: apply → show totals → charge → redeem(). This also makes cart-page recalculation cheap and side-effect free.

Wrap the facade in one service. Don't scatter LaravelDiscount:: calls across controllers, Livewire components, and jobs. One OrderDiscountService that takes a cart payload and returns your own DTO gives you a single place to fix rounding, add logging, and swap the package out later. Persist original_amount, discount_amount, the applied discount IDs, and the free-shipping flag on your order row so historical orders never re-compute against edited discounts.

Automatic discounts via applyMany(). For store-wide promos (seasonal sale, tiered spend-more-save-more), create code-less discounts and pass the whole set:

$result = LaravelDiscount::applyMany($activeAutomatics, $subtotal, $user, quantity: $itemCount);

Keep the candidate list narrow — query by starts_at/expires_at and isValid() rather than loading every discount row on every request.

Free shipping is a flag, not an amount. Never try to fold shipping into discountAmount. Compute shipping separately and only collapse it at the end:

$shipping = $result->hasFreeShipping() ? 0 : $carrier->rate();
$total    = $result->payableTotal($carrier->rate()); // or payableAmount() + $shipping

Buy X Get Y needs quantity everywhere. The API accepts quantity on apply(), applyCode(), applyMany(), and the applyDiscounts() trait method — and the other types ignore it. Set it once from your cart's total item count and pass it through every call path, otherwise a 6-item cart will be priced as if it were 1 line.

Use matchingTier() for upsell UI. It returns the tier the amount landed on (['min' => 5000000, 'value' => 10]), so you can render "You're at 10% — spend 3,000,000 more for 15%" without duplicating the ladder logic in Blade.

Non-throwing checks belong in FormRequests. isValid($discount, orderAmount: $total, user: $user) returns a boolean; use it for coupon-field validation rules. Reserve apply() for the real calculation path, where an exception is a bug or a race (code expired between page load and submit).

Attach discounts to models through the discountables relation for product- or category-scoped eligibility, and use the trait's applyDiscounts() when a model should own its own discount set. Combine with min_order_value for "free shipping over 1,000" style rules — min_order_value gates free shipping exactly like it gates monetary discounts.

Bulk-generate codes for campaigns and export them; generateCodes(100, 'VIP') returns a collection of unique, ambiguity-free codes. Don't hand-roll coupon strings.

Test with frozen time and factories of your own. $this->travelTo() around starts_at/expires_at boundaries, and assert on payableAmount(), hasFreeShipping(), and discountAmount rather than internal state. Because Discount is a normal model, RefreshDatabase plus direct create() calls is enough — no package-specific test helpers needed.

Gotchas and Tips

applyMany() is not a sum. Free shipping aside, monetary discounts compete — the best one wins and the rest are discarded. If you expect "10% off + 5 off" to stack, you will silently lose money. Verify the exact semantics against your version before you promise stacking in a marketing page, and model multi-discount totals yourself if the package's choice differs from your business rules.

Upgrading is a migration, not a composer update. Installs that migrated before buy_x_get_y, tiered, and free_shipping existed still have a narrow type enum that rejects them. Ship the widening migration from the README (DiscountType::values() plus ->change() on value). On Laravel 10 and below, ->change() requires doctrine/dbal.

Half-publishing migrations is a footgun. Once a migration is published, the package stops loading its own copy — good, that's how you avoid double-runs, but it means published migrations are frozen at publish time. Publish before you migrate, not after, and re-publish deliberately when the package adds columns.

Floats in money. Every amount in the result is a float. Round once, at the boundary where you persist or display (round($x, 2)), store minor units in your orders table, and never compare two floats with ===.

Redeem idempotently. Usage limits are enforced by redemption, so double-submits, retried webhooks, and refresh-on-success will burn a customer's single-use coupon. Guard redemption with a unique key on (order, discount) and wrap the check-and-increment in a transaction with lockForUpdate — concurrent checkouts can otherwise both pass the limit check.

The DiscountExpired event fires when you apply an expired discount, which is a strange place for it. Don't build scheduled cleanup or notification logic on it; query expires_at from a scheduled command instead, and treat the event purely as an observability signal.

Catch specific exceptions, not \Exception. DiscountNotFoundException, DiscountNotStartedException, and DiscountExpiredException are distinct outcomes with distinct UX (invalid code vs. not-yet-active vs. expired). Map each to a field-level validation message rather than a 500.

Mixed baskets get approximated. Buy-X-Get-Y prices free items at the basket's average unit price. A 10 and a 90 in the basket will not produce the "cheapest item free" result customers may expect — if you need line-level precision, compute the free-item set yourself from cart lines and apply a fixed discount for that amount.

Guests and null users. Usage-limit tracking keys off the configured user model; anonymous checkouts need the guest path in the README exercised explicitly, or per-user limits will either crash or count everyone as one user.

Run php artisan config:cache after editing the config — table names and the code-generation character set are read from cached config, and a stale cache produces "table not found" or codes with ambiguous characters.

Extension points, if you need them: the conditions JSON column is where Buy-X-Get-Y and tiered data live, and it is the cheapest place to add your own metadata ('campaign' => 'black-friday', 'channel' => 'web') without touching the schema. max_discount_amount caps the deduction for percentage discounts — always set it on any percentage coupon with a large order ceiling, or a 50% code on a 10,000 order becomes a 5,000 giveaway.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
htinlynn/genius-helper
maco-studios/hephaestus
elmogy/larafast
xeviant/laravel-paystack
gorilladash/laravel-website-sdk
martialbe/flysystem-oss
chondal/3312client
jeroen-g/flickr
opscale-co/nova-genbi
itsjustvita/laravel-retention
tequilarapido/trackit
gregsphotography/site-builder-filament
cabaoctavio/laravel-query-log
gregsphotography/site-builder-components
io-digital/clickatell
snowsoft/laravel-admin
oxygen/auth
relicsoftware/barakah-payment-gateway
tobya/work-with-composer
equipc/laravel-email-validator