mvonline/discount-laravel

A flexible Laravel package for managing discount codes and calculating discounts using a pipeline-based engine.

Maintainers

Package info

github.com/mvonline/discount-laravel

pkg:composer/mvonline/discount-laravel

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 1

v0.0.2 2026-04-04 14:53 UTC

This package is auto-updated.

Last update: 2026-04-04 14:55:30 UTC


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 via discount-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
  • DiscountType enum covering many strategies (full reference table below); DiscountType::...->isCalculationImplemented() marks types with built-in amount math in ApplyDiscountCalculation
  • Migrations for discount_codes and related tables (usage / conditions) for future use
  • Optional OpenAPI annotations and a discount-manager:generate-docs command (requires zircote/swagger-php in 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 pipeline
  • src/Pipeline/Pipes/* — individual steps
  • src/Models/DiscountCode.php — Eloquent model
  • routes/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).