mvonline / discount-laravel
A flexible Laravel package for managing discount codes and calculating discounts using a pipeline-based engine.
Requires
- php: ^8.2
- laravel/framework: ^12.0
Requires (Dev)
- orchestra/testbench: ^10.0
- phpunit/phpunit: ^11.0
- zircote/swagger-php: ^5.0
Suggests
- darkaonline/l5-swagger: For the Swagger UI view and published l5-swagger config used by optional documentation routes.
README
A Laravel package for managing discount codes and calculating cart discounts using a pipeline of validation and application steps. Built for Laravel 12 and PHP 8.2+.
Repository: github.com/mvonline/discount-laravel
Package name: mvonline/discount-laravel
Root namespace: Mvonline\DiscountLaravel
Features
- CRUD-style HTTP API under
/api/discount-codes(enable/disable viadiscount-manager.routes.enabled) - Discount calculation via Laravel’s
Pipeline, with ordered pipes (validate code, expiry, usage limits, basket minimums, user/group/first-time rules, then apply amount) - DTOs for cart, customer, and calculation requests/results
DiscountTypeenum covering many strategies (full reference table below);DiscountType::...->isCalculationImplemented()marks types with built-in amount math inApplyDiscountCalculation- Migrations for
discount_codesand related tables (usage / conditions) for future use - Optional OpenAPI annotations and a
discount-manager:generate-docscommand (requireszircote/swagger-phpin dev)
Installation
From GitHub (before Packagist)
{
"repositories": [
{
"type": "vcs",
"url": "https://github.com/mvonline/discount-laravel.git"
}
],
"require": {
"mvonline/discount-laravel": "dev-main"
}
}
Then:
composer update mvonline/discount-laravel
After Packagist publication
composer require mvonline/discount-laravel
The service provider is auto-discovered. Publish config and migrations:
php artisan vendor:publish --tag="discount-manager-config" php artisan vendor:publish --tag="discount-manager-migrations" php artisan migrate
Configuration file: config/discount-manager.php. Important options:
| Key | Purpose |
|---|---|
models.discount_code |
Eloquent model class (extend DiscountCode in your app if needed). |
calculation.apply_mode |
stack (default): apply all requested codes in order. best_single: evaluate each requested code alone and return the best discount. |
routes.enabled |
true (default): register package routes. Set false to ship routes yourself. |
routes.middleware |
Default ['api']. Add e.g. auth:sanctum for protected admin APIs. |
pipeline.pipes |
Ordered list of pipeline classes. |
getMaximumDiscount() always evaluates each eligible code alone and returns the single best valid result (highest total_discount).
Supported discount types
Values are stored on discount_codes.type and match Mvonline\DiscountLaravel\Enums\DiscountType.
Built-in discount math in ApplyDiscountCalculation: percentage, fixed_amount, percentage_with_cap (DiscountType::...->isCalculationImplemented()). Other types return 0 from the default matcher until you extend pipes—many still use validation pipes and conditions / metadata.
All examples assume:
use Mvonline\DiscountLaravel\Enums\DiscountType; use Mvonline\DiscountLaravel\Models\DiscountCode;
percentage
Percent off the applicable total (value = percent). Applied to the running remainder when multiple codes stack.
DiscountCode::create([ 'code' => 'SAVE20', 'name' => '20% off', 'type' => DiscountType::PERCENTAGE, 'value' => 20, 'is_active' => true, 'can_be_combined' => true, ]);
fixed_amount
Fixed currency amount off the total (cannot exceed what remains on the cart).
DiscountCode::create([ 'code' => 'FLAT15', 'name' => '$15 off', 'type' => DiscountType::FIXED_AMOUNT, 'value' => 15, 'is_active' => true, ]);
percentage_with_cap
Percent in value; max discount in metadata (see also Percentage with cap under Usage).
DiscountCode::create([ 'code' => 'PCT25CAP', 'name' => '25% off, max $30', 'type' => DiscountType::PERCENTAGE_WITH_CAP, 'value' => 25, 'is_active' => true, 'metadata' => ['max_discount_amount' => 30.00], ]);
specific_user
Restricts redemption to listed user IDs (CheckSpecificUserDiscount). Pair with custom amount logic or extend ApplyDiscountCalculation—default amount for this type is 0.
DiscountCode::create([ 'code' => 'USER42ONLY', 'name' => 'User 42 perk', 'type' => DiscountType::SPECIFIC_USER, 'value' => 10, 'is_active' => true, 'conditions' => ['allowed_users' => [42]], ]);
specific_group
Requires the customer to be in one of the allowed groups (CheckSpecificGroupDiscount).
DiscountCode::create([ 'code' => 'VIPONLY', 'name' => 'VIP discount', 'type' => DiscountType::SPECIFIC_GROUP, 'value' => 15, 'is_active' => true, 'conditions' => ['allowed_groups' => ['vip', 'gold']], ]);
specific_product
Line-item / SKU rules—implement in your app or custom pipes using cart.items and conditions.
DiscountCode::create([ 'code' => 'SKU123', 'name' => 'SKU 123 promo', 'type' => DiscountType::SPECIFIC_PRODUCT, 'value' => 5, 'is_active' => true, 'conditions' => ['product_ids' => [123, 456]], ]);
bogo
BOGO rules in metadata; fulfillment typically needs custom pipes or checkout logic.
DiscountCode::create([ 'code' => 'BOGOHAT', 'name' => 'BOGO hats', 'type' => DiscountType::BOGO, 'value' => 0, 'is_active' => true, 'metadata' => ['buy_qty' => 1, 'get_qty' => 1, 'scope' => 'sku:HAT-01'], ]);
buy_x_get_y
Threshold promotion; encode X/Y in metadata / conditions.
DiscountCode::create([ 'code' => 'BUY3GET1', 'name' => 'Buy 3 get 1', 'type' => DiscountType::BUY_X_GET_Y, 'value' => 0, 'is_active' => true, 'metadata' => ['buy' => 3, 'get' => 1, 'discount_on_get' => 100], ]);
with_expiry
Campaign label; real validity is starts_at / expires_at (see CheckExpiryDate).
DiscountCode::create([ 'code' => 'SUMMER24', 'name' => 'Summer sale', 'type' => DiscountType::WITH_EXPIRY, 'value' => 10, 'is_active' => true, 'starts_at' => now()->startOfMonth(), 'expires_at' => now()->addMonths(3), ]);
first_n_users
Cap redemptions with usage_limit (first N successful uses).
DiscountCode::create([ 'code' => 'FIRST500', 'name' => 'First 500 customers', 'type' => DiscountType::FIRST_N_USERS, 'value' => 50, 'is_active' => true, 'usage_limit' => 500, 'usage_count' => 0, ]);
minimum_basket
CheckMinimumBasketValue enforces minimum_basket_value for any type. For built-in percent/fixed math, use PERCENTAGE or FIXED_AMOUNT with minimum_basket_value set. The MINIMUM_BASKET enum value is a semantic label if you extend amount logic yourself.
DiscountCode::create([ 'code' => 'MIN100P10', 'name' => '10% on $100+', 'type' => DiscountType::PERCENTAGE, 'value' => 10, 'minimum_basket_value' => 100, 'is_active' => true, ]);
bundle
Define which SKUs must appear together; allocation in custom pipes.
DiscountCode::create([ 'code' => 'BUNDLEABC', 'name' => 'A+B+C bundle', 'type' => DiscountType::BUNDLE, 'value' => 20, 'is_active' => true, 'conditions' => ['required_skus' => ['A-1', 'B-2', 'C-3']], ]);
category_based
Limit to categories; cart lines should carry category ids your pipes can read.
DiscountCode::create([ 'code' => 'CATSALE', 'name' => 'Electronics sale', 'type' => DiscountType::CATEGORY_BASED, 'value' => 12, 'is_active' => true, 'conditions' => ['category_ids' => [10, 11]], ]);
shipping
Flag shipping-focused promos; discount shipping via CartDTO + custom logic or fixed amount on shipping component.
DiscountCode::create([ 'code' => 'FREESHIP', 'name' => 'Free shipping', 'type' => DiscountType::SHIPPING, 'value' => 0, 'is_active' => true, 'metadata' => ['shipping_discount_type' => 'free', 'max_shipping_value' => 9.99], ]);
loyalty_points
Map points to money in metadata; validate balance outside the package.
DiscountCode::create([ 'code' => 'POINTS500', 'name' => '500 points', 'type' => DiscountType::LOYALTY_POINTS, 'value' => 5.00, 'is_active' => true, 'metadata' => ['points_cost' => 500, 'points_per_currency' => 100], ]);
referral
Referral campaign id / terms in metadata.
DiscountCode::create([ 'code' => 'REF-ALICE', 'name' => 'Alice refers you', 'type' => DiscountType::REFERRAL, 'value' => 10, 'is_active' => true, 'metadata' => ['referrer_id' => 101, 'campaign' => 'spring'], ]);
bulk_purchase
Volume tiers in conditions / metadata.
DiscountCode::create([ 'code' => 'BULK', 'name' => 'Bulk tiers', 'type' => DiscountType::BULK_PURCHASE, 'value' => 0, 'is_active' => true, 'metadata' => ['tiers' => [['min_qty' => 10, 'percent' => 5], ['min_qty' => 50, 'percent' => 12]]], ]);
payment_method
Allowed methods in conditions; validate in a custom pipe (e.g. read from request).
DiscountCode::create([ 'code' => 'CARDONLY', 'name' => 'Card discount', 'type' => DiscountType::PAYMENT_METHOD, 'value' => 3, 'is_active' => true, 'conditions' => ['allowed_payment_methods' => ['card', 'apple_pay']], ]);
first_time_buyer
CheckFirstTimeBuyerDiscount requires CustomerDTO::isFirstTimeBuyer === true. Amount still needs PERCENTAGE/FIXED or custom pipe unless you extend the matcher.
DiscountCode::create([ 'code' => 'WELCOME', 'name' => 'First order', 'type' => DiscountType::FIRST_TIME_BUYER, 'value' => 15, 'is_active' => true, ]);
seasonal
Label + date window for holiday/season campaigns.
DiscountCode::create([ 'code' => 'BLACKFRIDAY', 'name' => 'Black Friday', 'type' => DiscountType::SEASONAL, 'value' => 30, 'is_active' => true, 'starts_at' => '2026-11-24 00:00:00', 'expires_at' => '2026-11-30 23:59:59', ]);
limited_quantity
Global or per-code stock using usage_limit and your inventory rules.
DiscountCode::create([ 'code' => 'LIMIT50', 'name' => 'Limited stock', 'type' => DiscountType::LIMITED_QUANTITY, 'value' => 20, 'is_active' => true, 'usage_limit' => 50, 'metadata' => ['sku_stock' => ['SKU-1' => 20]], ]);
location_based
Regions / stores in conditions.
DiscountCode::create([ 'code' => 'NYCONLY', 'name' => 'NYC stores', 'type' => DiscountType::LOCATION_BASED, 'value' => 8, 'is_active' => true, 'conditions' => ['regions' => ['US-NY'], 'store_ids' => [7, 8]], ]);
customer_segment
Match CRM segments from CustomerDTO / conditions.
DiscountCode::create([ 'code' => 'HIGHVALUE', 'name' => 'High-value segment', 'type' => DiscountType::CUSTOMER_SEGMENT, 'value' => 12, 'is_active' => true, 'conditions' => ['segments' => ['high_value', 'repeat_buyer']], ]);
membership
Club / tier offers—often aligned with customer.groups.
DiscountCode::create([ 'code' => 'CLUB', 'name' => 'Members club', 'type' => DiscountType::MEMBERSHIP, 'value' => 10, 'is_active' => true, 'conditions' => ['membership_tiers' => ['gold', 'platinum']], ]);
gift_card
Treat as stored value; integrate ledger in your app.
DiscountCode::create([ 'code' => 'GC-9F3A', 'name' => 'Gift card', 'type' => DiscountType::GIFT_CARD, 'value' => 50.00, 'is_active' => true, 'metadata' => ['balance' => 50.00, 'currency' => 'USD'], ]);
tiered
Spend- or quantity-based tiers in metadata; implement tier resolution in a custom pipe.
DiscountCode::create([ 'code' => 'TIERED', 'name' => 'Spend tiers', 'type' => DiscountType::TIERED, 'value' => 0, 'is_active' => true, 'metadata' => ['tiers' => [['min' => 0, 'pct' => 5], ['min' => 200, 'pct' => 10]]], ]);
flash
Short window flash sale.
DiscountCode::create([ 'code' => 'FLASH2H', 'name' => '2h flash', 'type' => DiscountType::FLASH, 'value' => 40, 'is_active' => true, 'starts_at' => now(), 'expires_at' => now()->addHours(2), ]);
user_anniversary
Eligibility from account/signup dates—validate in a custom pipe.
DiscountCode::create([ 'code' => 'ANNIV', 'name' => 'Anniversary', 'type' => DiscountType::USER_ANNIVERSARY, 'value' => 15, 'is_active' => true, 'metadata' => ['match' => 'signup_anniversary_month'], ]);
app_exclusive
Channel gate—pass e.g. channel in CustomerDTO::metadata.
DiscountCode::create([ 'code' => 'APPONLY', 'name' => 'App exclusive', 'type' => DiscountType::APP_EXCLUSIVE, 'value' => 7, 'is_active' => true, 'conditions' => ['channels' => ['ios', 'android']], ]);
free_gift
Adds a gift line in order management; amount here is often 0 until you model gift SKU in metadata.
DiscountCode::create([ 'code' => 'GIFTMUG', 'name' => 'Free mug', 'type' => DiscountType::FREE_GIFT, 'value' => 0, 'is_active' => true, 'metadata' => ['gift_sku' => 'MUG-01', 'max_gifts' => 1], ]);
upgrade
Upgrade path between products/plans.
DiscountCode::create([ 'code' => 'UPGRADE', 'name' => 'Pro upgrade', 'type' => DiscountType::UPGRADE, 'value' => 99.00, 'is_active' => true, 'conditions' => ['from_plan' => 'basic', 'to_plan' => 'pro'], ]);
subscription
Subscription billing hooks—eligibility flags for your biller.
DiscountCode::create([ 'code' => 'SUB3MO', 'name' => '3 months off', 'type' => DiscountType::SUBSCRIPTION, 'value' => 10, 'is_active' => true, 'metadata' => ['interval' => 'month', 'duration_cycles' => 3], ]);
milestone
Lifetime spend / order count thresholds.
DiscountCode::create([ 'code' => 'MILE10K', 'name' => '$10k lifetime spend', 'type' => DiscountType::MILESTONE, 'value' => 25, 'is_active' => true, 'metadata' => ['min_lifetime_spend' => 10000], ]);
refer_a_friend
Double-sided referral metadata for your referral service.
DiscountCode::create([ 'code' => 'RAF2026', 'name' => 'Refer a friend', 'type' => DiscountType::REFER_A_FRIEND, 'value' => 20, 'is_active' => true, 'metadata' => ['referee_reward' => 20, 'referrer_reward' => 20], ]);
product_launch
Launch window + catalog flags.
DiscountCode::create([ 'code' => 'NEWPHONE', 'name' => 'Launch week', 'type' => DiscountType::PRODUCT_LAUNCH, 'value' => 50, 'is_active' => true, 'conditions' => ['launch_product_ids' => [9001]], 'expires_at' => now()->addWeek(), ]);
donation_based
Round-up / charity—custom amount rules.
DiscountCode::create([ 'code' => 'ROUNDUP', 'name' => 'Round up for charity', 'type' => DiscountType::DONATION_BASED, 'value' => 0, 'is_active' => true, 'metadata' => ['charity_id' => 'red-cross', 'round_up_to' => 1.00], ]);
buy_more_save_more
Progressive table in metadata.
DiscountCode::create([ 'code' => 'MORESAVE', 'name' => 'Buy more save more', 'type' => DiscountType::BUY_MORE_SAVE_MORE, 'value' => 0, 'is_active' => true, 'metadata' => ['steps' => [['min' => 100, 'pct' => 5], ['min' => 250, 'pct' => 10]]], ]);
combo
Fixed combo price / discount for a preset basket mix.
DiscountCode::create([
'code' => 'COMBOPIZZA',
'name' => 'Pizza combo',
'type' => DiscountType::COMBO,
'value' => 19.99,
'is_active' => true,
'conditions' => ['combo_line_items' => [['sku' => 'PZ-1'], ['sku' => 'DR-2']]]],
]);
exchange
Trade-in value and partner SKUs—integrate with ERP/POS.
DiscountCode::create([ 'code' => 'TRADEOLD', 'name' => 'Trade-in', 'type' => DiscountType::EXCHANGE, 'value' => 200, 'is_active' => true, 'metadata' => ['trade_in_sku' => 'OLD-PHONE', 'valuation_source' => 'pos'], ]);
Usage
Conditions & metadata: who passes what
There are two different places “conditions” and “metadata” show up:
| Where | When it is set | Purpose |
|---|---|---|
discount_codes.conditions and discount_codes.metadata |
When you create or update the code (admin, seeder, or POST /api/discount-codes) |
Rules and extra data for that code (allowed users, groups, caps, tiers, etc.). Shoppers do not send these at checkout—they are loaded from the database by code string. |
CartDTO::metadata, CustomerDTO::metadata, DiscountCalculationRequestDTO::metadata |
On each calculation request (checkout / quote) | Runtime context for your app or custom pipes (channel, device, payment method already chosen, A/B flags, etc.). Built-in pipes mostly use typed fields (customer.groups, customer.id, …), not these bags. |
At checkout the buyer only passes the code(s)—for example ['VIP2024']. The engine runs DiscountCode::whereIn('code', …), so stored conditions / metadata on each row are always applied automatically.
Built-in pipes and stored conditions keys (on the DiscountCode model):
| Pipe | Reads from the loaded code | Reads from DTOs |
|---|---|---|
| CheckSpecificUserDiscount | conditions['allowed_users'] |
customer.id |
| CheckSpecificGroupDiscount | conditions['allowed_groups'] |
customer.groups |
| CheckMinimumBasketValue | minimum_basket_value |
cart.total |
| ApplyDiscountCalculation (percentage with cap) | metadata['max_discount_amount'] or metadata['cap'] |
cart totals via context |
1) Define rules on the code (admin / API)
use Mvonline\DiscountLaravel\Enums\DiscountType; use Mvonline\DiscountLaravel\Models\DiscountCode; DiscountCode::create([ 'code' => 'VIP15', 'name' => 'VIP 15%', 'type' => DiscountType::SPECIFIC_GROUP, 'value' => 15, 'is_active' => true, 'conditions' => [ 'allowed_groups' => ['vip', 'gold'], ], 'metadata' => [ 'marketing_campaign' => 'spring-2026', ], ]);
2) Apply codes at checkout (PHP)—only codes + cart + customer context
use Mvonline\DiscountLaravel\DTOs\CartDTO; use Mvonline\DiscountLaravel\DTOs\CustomerDTO; use Mvonline\DiscountLaravel\DTOs\DiscountCalculationRequestDTO; use Mvonline\DiscountLaravel\Services\DiscountCalculator; $cartDTO = new CartDTO( items: collect([['id' => 1, 'name' => 'Item', 'price' => 50, 'quantity' => 2]]), subtotal: 100.00, tax: 10.00, shipping: 5.00, total: 115.00, currency: 'USD', metadata: ['store_id' => 'NYC-07'], ); $customerDTO = new CustomerDTO( id: 42, type: 'user', groups: ['vip'], segments: ['high_value'], location: 'US-NY', paymentMethod: 'card', isFirstTimeBuyer: false, metadata: ['channel' => 'ios'], ); $request = new DiscountCalculationRequestDTO( cart: $cartDTO, customer: $customerDTO, discountCodes: ['VIP15'], metadata: ['request_id' => 'uuid-for-logging'], ); $result = app(DiscountCalculator::class)->calculate($request);
conditions / metadata on the code are not repeated in $request—they are read from the DiscountCode rows loaded for VIP15.
3) Same request via HTTP (POST /api/discount-codes/validate)
Optional fields match CartDTO::fromArray / CustomerDTO::fromArray: cart.metadata, cart.currency, customer.segments, customer.location, customer.payment_method, customer.metadata, and top-level metadata.
{
"cart": {
"items": [
{ "id": 1, "name": "Item", "price": 50, "quantity": 2 }
],
"subtotal": 100,
"tax": 10,
"shipping": 5,
"total": 115,
"currency": "USD",
"metadata": { "store_id": "NYC-07" }
},
"customer": {
"id": 42,
"type": "user",
"groups": ["vip"],
"segments": ["high_value"],
"location": "US-NY",
"payment_method": "card",
"is_first_time_buyer": false,
"metadata": { "channel": "ios" }
},
"discount_codes": ["VIP15"],
"metadata": { "request_id": "optional-for-your-app" }
}
To set conditions / metadata on the code itself, use POST /api/discount-codes (create) or PUT /api/discount-codes/{id} with a JSON body that includes conditions and metadata:
{
"code": "VIP15",
"name": "VIP 15%",
"type": "specific_group",
"value": 15,
"is_active": true,
"can_be_combined": true,
"conditions": {
"allowed_groups": ["vip", "gold"]
},
"metadata": {
"marketing_campaign": "spring-2026"
}
}
Calculate a discount in PHP
use Mvonline\DiscountLaravel\DTOs\CartDTO; use Mvonline\DiscountLaravel\DTOs\CustomerDTO; use Mvonline\DiscountLaravel\DTOs\DiscountCalculationRequestDTO; use Mvonline\DiscountLaravel\Services\DiscountCalculator; $cartDTO = new CartDTO( items: collect([/* cart line items */]), subtotal: 100.00, tax: 10.00, shipping: 5.00, total: 115.00 ); $customerDTO = new CustomerDTO( id: 1, type: 'user', groups: ['vip'], isFirstTimeBuyer: false ); $request = new DiscountCalculationRequestDTO( cart: $cartDTO, customer: $customerDTO, discountCodes: ['SUMMER2024'] ); $result = app(DiscountCalculator::class)->calculate($request); if ($result->isValid) { // $result->totalDiscount, $result->finalTotal, $result->toArray() for JSON } else { // $result->errorMessage }
HTTP API (package routes)
Routes are registered with the api middleware group and prefix api:
| Method | Path | Description |
|---|---|---|
| GET | /api/discount-codes |
Paginated list |
| POST | /api/discount-codes |
Create |
| GET | /api/discount-codes/{discountCode} |
Show |
| PUT | /api/discount-codes/{discountCode} |
Update |
| DELETE | /api/discount-codes/{discountCode} |
Soft delete |
| POST | /api/discount-codes/validate |
Validate cart + codes |
| POST | /api/discount-codes/maximum-discount |
Best single eligible code for the cart (highest discount) |
| POST | /api/discount-codes/{discountCode}/track-usage |
Increment usage_count |
Validation endpoints return the same shape as DiscountCalculationResultDTO::toArray() (snake_case keys).
Custom pipeline pipes
Override discount-manager.pipeline.pipes in config with your own classes extending Mvonline\DiscountLaravel\Pipeline\Pipe and implementing handle(DiscountCalculationContext $context, Closure $next).
Percentage with cap
Store the percentage in value and set a monetary cap in JSON metadata, for example:
{
"max_discount_amount": 25.00
}
Alternatively use the key cap for the same purpose.
Project layout
src/Services/DiscountCalculator.php— orchestrates the pipelinesrc/Pipeline/Pipes/*— individual stepssrc/Models/DiscountCode.php— Eloquent modelroutes/api.php— package routes
Testing
composer test
Uses Orchestra Testbench and PHPUnit 11.
Contributing
Issues and pull requests are welcome on GitHub.
License
See LICENSE (MIT).