PHPackages                             lalalili/discount - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Utility &amp; Helpers](/categories/utility)
4. /
5. lalalili/discount

ActiveLibrary[Utility &amp; Helpers](/categories/utility)

lalalili/discount
=================

Config-driven discount and coupon kernel for Laravel projects.

v3.0.0(1w ago)10proprietaryPHPPHP ^8.3CI passing

Since Feb 15Pushed 1w agoCompare

[ Source](https://github.com/lalalili/discount)[ Packagist](https://packagist.org/packages/lalalili/discount)[ RSS](/packages/lalalili-discount/feed)WikiDiscussions main Synced today

READMEChangelog (3)Dependencies (13)Versions (10)Used By (0)

lalalili/discount
=================

[](#lalalilidiscount)

Config-driven discount and coupon kernel for Laravel projects.

Scope
-----

[](#scope)

This package provides reusable promotion engines, coupon application orchestration, and DTO/context objects:

- Product price calculation (`DiscountEngineInterface`)
- Cart promotion condition generation (`CartPromotionEngineInterface`)
- Coupon eligibility validation (`CouponEligibilityInterface`)
- Coupon code generation (`CouponCodeGeneratorInterface`)
- Coupon discount calculation (`CouponDiscountEngineInterface`)
- Coupon validation orchestration (`CouponApplicationServiceInterface`)
- Coupon condition payload generation for app cart adapters (`CouponConditionPayloadFactory`)

Out of scope (kept in application adapter layer):

- Eloquent queries and persistence implementation details
- Session/Cookie/Auth orchestration
- Admin UI
- Domain-specific flow control (events, jobs, notification orchestration)

`CouponApplicationServiceInterface` is provided by this package, but coupon data lookup / usage checks / inventory updates must be implemented by your app via `CouponRepositoryInterface`.

Requirements
------------

[](#requirements)

- PHP `^8.3`
- Laravel components `^12.0|^13.0`

Public Interfaces
-----------------

[](#public-interfaces)

The package API is stable at interface level:

- `DiscountEngineInterface::price(ProductContext $product, PromotionSet $promotions): PriceResult`
- `CartPromotionEngineInterface::apply(CartContext $cart, PromotionSet $promotions): CartAdjustmentResult`
- `CouponEligibilityInterface::validate(CouponContext $coupon, CartContext $cart, UserContext $user): EligibilityResult`
- `CouponCodeGeneratorInterface::generate(CodeContext $context): string`
- `CouponDiscountEngineInterface::discount(float $orderTotal, CouponContext $coupon): CouponDiscountResult`
- `CouponApplicationServiceInterface::validate(CouponKind $kind, string $code, CartContext $cart, UserContext $user): CouponValidationResult`

Core Types
----------

[](#core-types)

### Enums

[](#enums)

- `CouponAmountMode`: `auto | fixed | rate`
- `CouponKind`: `member | promotion`

### DTOs

[](#dtos)

- `CouponData`
    - `code`, `kind`, `scope`, `triggerAmount`, `amount`, `amountMode`, `status`, `limitQty`, `leftQty`, `userId`, `attributes`
- `CouponDiscountResult`
    - `valid`, `discount`, `finalTotal`, `reason`, `reasonCode`
- `CouponValidationResult`
    - `eligible`, `coupon`, `discount`, `finalTotal`, `reason`, `reasonCode`, `pricingTrace`
- `CouponConditionPayload`
    - `type`, `target`, `value`, `order`, `attributes`
- `PricingTrace`
    - list wrapper for `PricingTraceEntry`
- `PricingTraceEntry`
    - stable array fields: `stage`, `source`, `status`, `scope`, `kind`, `code`, `id`, `amount`, `final_total`, `reason_code`, `reason`, `metadata`

### PricingTrace Contract

[](#pricingtrace-contract)

`PricingTrace` is an additive public DTO introduced for discount `2.5.x`. It is an in-memory checkout and lifecycle trace, not a persistence/audit-log feature.

Enum values currently emitted by the package/app adapters:

- `source`: `promotion`, `coupon`
- `status`: `applied`, `skipped`, `failed`, `issued`, `restored`
- `stage`: `promotion_refresh`, `coupon_validate`, `coupon_apply`, `coupon_issue`, `coupon_redeem`, `coupon_inventory`, `coupon_restore`

`CartPromotionRefreshResult::$pricingTrace` is normalized from `promotionDecisions` with `stage=promotion_refresh` and `source=promotion`.

`CouponValidationResult::$pricingTrace` is optional and defaults to `null` for backward compatibility. `DefaultCouponApplicationService` populates a `coupon_validate` entry for eligible and failed validation outcomes. `CouponDiscountResult` intentionally does not carry trace data; application services combine discount and validation trace when they apply a coupon.

`Lalalili\Discount\Support\PricingTraceFormatter` provides stable helpers for app adapters:

- `normalize()` converts `PricingTrace`, `PricingTraceEntry`, single-entry arrays, and entry lists into a list of arrays.
- `mergeLatestByIdentity()` replaces duplicate entries by `stage/source/kind/id-or-code` and trims the list for cart context storage.
- `summarize()` returns compact counts by stage, source, status, and reason code for pipeline metadata.

Release note for `2.5.1`: the formatter is a public app-adapter helper. It does not change the `PricingTrace` / `PricingTraceEntry` array shape and does not add persistence. Consumer apps should update lock files after the tag, then remove long-lived duplicate merge/summary logic once the helper is available from `vendor/`.

Application cart adapters should store checkout coupon trace under cart context metadata such as:

```
$cart->withContext($cart->getContext()->with('pricing_trace', [
    'coupon' => $validationResult->pricingTrace?->toArray() ?? [],
]));
```

Coupon cart conditions should include the applied entry in condition attributes:

```
[
    'attributes' => [
        'pricing_trace_entry' => $entry->toArray(),
    ],
]
```

`Lalalili\Discount\Support\CouponConditionPayloadFactory` provides a small app-adapter helper for checkout coupon conditions. It returns a plain `CouponConditionPayload` and does not depend on `lalalili/laravelshoppingcart`:

```
use Lalalili\Discount\Enums\CouponKind;
use Lalalili\Discount\Support\CouponConditionPayloadFactory;

$payload = app(CouponConditionPayloadFactory::class)->make(
    CouponKind::Promotion,
    50,
    $couponApplyEntry,
);

$conditionArgs = $payload->toArray([
    'name' => __('cruds.coupon.promotion'),
]);
```

Stable payload values:

- `type`: `member_coupon` or `promotion_coupon`
- `target`: `total`
- `order`: `10` for member coupon, `11` for promotion coupon
- `value`: negative numeric discount amount
- `attributes.pricing_trace_entry`: `PricingTraceEntry::toArray()`

Out of scope for this stage: DB audit logs, moving coupon processing into promotion refresh, and changing the public API of `lalalili/laravelshoppingcart`.

### Context Changes

[](#context-changes)

`CouponContext` now supports:

- `scope`
- `triggerAmount`
- `amount`
- `amountMode` (`CouponAmountMode|string|null`, optional, default `auto`)

Install
-------

[](#install)

### Option A: Local path repository

[](#option-a-local-path-repository)

In application `composer.json`:

```
{
  "repositories": [
    {
      "type": "path",
      "url": "packages/discount",
      "options": {
        "symlink": true
      }
    }
  ],
  "require": {
    "lalalili/discount": "^2.5"
  }
}
```

Then run:

```
composer update lalalili/discount
```

### Option B: Private VCS repository (recommended for other projects)

[](#option-b-private-vcs-repository-recommended-for-other-projects)

In application `composer.json`:

```
{
  "repositories": [
    {
      "type": "vcs",
      "url": "git@github.com:lalalili/discount.git"
    }
  ],
  "require": {
    "lalalili/discount": "^2.5"
  }
}
```

Then run:

```
composer update lalalili/discount
```

Laravel Setup
-------------

[](#laravel-setup)

Publish default config (optional):

```
php artisan vendor:publish --tag=discount-config
```

The package reads mappings from `config/discount.php`.

### Required Binding for Coupon Application Service

[](#required-binding-for-coupon-application-service)

To use `CouponApplicationServiceInterface`, your app must bind `CouponRepositoryInterface`:

```
use Lalalili\Discount\Contracts\CouponRepositoryInterface;
use App\Services\Events\Support\EloquentCouponRepository;

$this->app->singleton(CouponRepositoryInterface::class, EloquentCouponRepository::class);
```

Config-Driven Model
-------------------

[](#config-driven-model)

`config/discount.php` sections:

- `event.type_role_map`
- `event.priorities`
- `coupon.scope_map`
- `coupon.code.prefixes`
- `coupon.code.templates`
- `coupon.code.tokens`
- `cart.roles`
- `cart.gift_resolver`

With this design, another project only needs config changes (type mapping, scope mapping, code template, cart role mapping) without rewriting engine logic.

Package defaults only include the active event types used by the host app. Legacy event types such as `2` and `5` are not mapped by default.

If another project still needs sequential or stackable discount behavior, define a custom event type in `event.type_role_map` and map it to `stackable_discount`.

Example:

```
'event' => [
    'type_role_map' => [
        901 => 'stackable_discount',
    ],
    'priorities' => [
        'pricing' => [
            'exclusive_price',
            'exclusive_discount',
            'group_rebate',
            'single_discount',
            'stackable_discount',
        ],
    ],
],
```

Minimal Usage
-------------

[](#minimal-usage)

### Product pricing (79折)

[](#product-pricing-79折)

```
use Lalalili\Discount\Contexts\ProductContext;
use Lalalili\Discount\Contexts\PromotionContext;
use Lalalili\Discount\Contexts\PromotionSet;
use Lalalili\Discount\Engines\DefaultDiscountEngine;

$engine = new DefaultDiscountEngine();

$result = $engine->price(
    new ProductContext(1000),
    new PromotionSet([
        new PromotionContext(type: 1, sort: 1, discountAmount: 0.79),
    ])
);

$price = $result->price; // 790
```

### Coupon discount calculation (fixed + rate)

[](#coupon-discount-calculation-fixed--rate)

```
use Lalalili\Discount\Contexts\CouponContext;
use Lalalili\Discount\Engines\DefaultCouponDiscountEngine;
use Lalalili\Discount\Enums\CouponAmountMode;

$engine = new DefaultCouponDiscountEngine();

$fixed = $engine->discount(
    1000,
    new CouponContext(scope: 0, triggerAmount: null, amount: 100, amountMode: CouponAmountMode::Fixed)
);

$rate = $engine->discount(
    1000,
    new CouponContext(scope: 0, triggerAmount: null, amount: 0.9, amountMode: CouponAmountMode::Rate)
);
```

### Coupon application service (member / promotion)

[](#coupon-application-service-member--promotion)

```
use Lalalili\Discount\Contexts\CartContext;
use Lalalili\Discount\Contexts\UserContext;
use Lalalili\Discount\Contracts\CouponApplicationServiceInterface;
use Lalalili\Discount\Enums\CouponKind;

$service = app(CouponApplicationServiceInterface::class);

$result = $service->validate(
    CouponKind::Promotion,
    'PROMO123',
    new CartContext(
        orderTotal: 1200,
        allAmount: 1200,
        bookAmount: 1200,
        ebookAmount: 0,
        specificProductsAmount: 1200,
        hasBook: true,
        hasEbook: false,
        hasSpecificProducts: true,
    ),
    new UserContext(123),
);

$isEligible = $result->eligible;
$discount = $result->discount;
```

### Coupon code generation

[](#coupon-code-generation)

```
use Lalalili\Discount\Contexts\CodeContext;
use Lalalili\Discount\Engines\DefaultCouponCodeGenerator;

$engine = new DefaultCouponCodeGenerator();

$code = $engine->generate(new CodeContext(
    typeValue: 13,
    userId: 123,
    count: 1,
    existsChecker: fn (string $candidate): bool => false,
));
```

### Cart adjustment generation

[](#cart-adjustment-generation)

```
use Lalalili\Discount\Contexts\CartContext;
use Lalalili\Discount\Contexts\PromotionContext;
use Lalalili\Discount\Contexts\PromotionSet;
use Lalalili\Discount\Engines\DefaultCartPromotionEngine;

$engine = new DefaultCartPromotionEngine();

$result = $engine->apply(
    new CartContext(
        orderTotal: 0,
        allAmount: 0,
        bookAmount: 0,
        ebookAmount: 0,
        specificProductsAmount: 0,
        hasBook: false,
        hasEbook: false,
        hasSpecificProducts: false,
        productId: 1001,
        productPrice: 1200,
        selectedGroupRebateEventId: null,
    ),
    new PromotionSet([
        new PromotionContext(type: 1, eventId: 201, name: 'Single discount', discountAmount: 0.8),
    ])
);

$adjustments = $result->adjustments;
```

Stable Reason Codes
-------------------

[](#stable-reason-codes)

`CouponValidationResult::reasonCode` and `CouponDiscountResult::reasonCode` are stable public contract fields.

- `COUPON_NOT_FOUND`
- `AUTH_REQUIRED`
- `COUPON_ALREADY_USED`
- `COUPON_OUT_OF_STOCK`
- `DISCOUNT_INVALID`
- `ELIGIBILITY_FAILED`

Cart Promotion Integration Contract
-----------------------------------

[](#cart-promotion-integration-contract)

`CartPromotionRefreshResult` exposes both the legacy arrays (`appliedPromotions`, `skippedPromotions`) and the normalized `promotionDecisions` array. New integrations should read `promotionDecisions`; existing callers can continue reading the legacy arrays.

Stable skipped promotion reasons:

- `threshold_not_met`
- `exclusive_conflict`
- `gift_unresolved`
- `gift_out_of_stock`
- `not_selected`

Responsibilities:

- `lalalili/discount` computes item adjustments, cart rebate/gift adjustments, selected type 6 group rebates, totals, and promotion decisions. It does not mutate a shopping cart instance.
- The application adapter loads local Product/Event/Gift models, builds `CartPromotionRefreshInput`, resolves gift stock/fulfillment behavior, and writes the returned conditions/items back to the cart.
- `lalalili/laravelshoppingcart` triggers refresh through `before_totals` pipelines and stores observability data in `CartPipelineResult::metadata` and `snapshot()['pipelines']`.
- Apps may skip refresh when a locally computed `promotion_refresh_signature` is unchanged, but checkout entry should force one final refresh before payment.

Recommended pipeline metadata:

- `promotion_version`
- `refresh_reason`
- `duration_ms`
- `promotion_refresh_signature`
- `applied_count`
- `skipped_count`

Coupon Flows (App Adapter Layer)
--------------------------------

[](#coupon-flows-app-adapter-layer)

Keep these flows in your application and call kernel engines:

- `PROMOTION (2)`: admin-created promo coupon flow
- `REGISTER (11)`: issue after user registration
- `BIRTHDAY (12)`: monthly birthday scheduler
- `FIRST_ORDER (13)`: issue after first completed order (once per lifetime)

Recommended `FIRST_ORDER (13)` dedup rule:

- Do not issue if existing coupon for same user with `created_by=CouponForFirstOrder` and usable/used status already exists.

Recommended deprecated runtime guard:

- If coupon type is `22`, skip and log warning (for example `legacy_coupon_type_detected`).
- Keep `21` available if your app still supports LINE binding coupon issuance.

Versioning Note
---------------

[](#versioning-note)

Current package `composer.json` version is `2.5.2`. This release is a minor update from `2.5.x` and does not introduce breaking API changes. See `CHANGELOG.md` for release notes and `RELEASING.md` for sync/tag SOP.

Local Quality Checks
--------------------

[](#local-quality-checks)

Inside package directory:

```
composer install
composer analyse
```

Quick Onboarding for Another Project
------------------------------------

[](#quick-onboarding-for-another-project)

1. Install `lalalili/discount` via VCS + tag.
2. Publish or create `config/discount.php`.
3. Map local event/coupon enum values in config.
4. Bind `CouponRepositoryInterface` to your adapter implementation.
5. Set `cart.gift_resolver` (or `null` if gift not used).
6. Build product/cart/user contexts from your local models.
7. Wrap `CouponConditionPayloadFactory` in a local cart adapter that adds the condition name and instantiates your cart condition class.
8. Add a local reason-message resolver for API/UI copy.
9. Keep order lifecycle in the app layer: member coupon deactivation, promotion inventory decrement, and cancel-order restore.
10. Keep issuing flows in app adapters (`register`, `birthday`, `first order`).
11. Add runtime skip policy for deprecated coupon types if your data still contains legacy coupon types.
12. Run smoke tests for checkout pricing, coupon application, order creation, inventory decrement, cancel restore, and coupon issuance.

###  Health Score

43

—

FairBetter than 89% of packages

Maintenance98

Actively maintained with recent releases

Popularity2

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity55

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~15 days

Recently: every ~10 days

Total

9

Last Release

13d ago

Major Versions

2.5.3 → v3.0.02026-06-21

PHP version history (2 changes)2.0.0PHP ^8.4

2.5.2PHP ^8.3

### Community

Maintainers

![](https://www.gravatar.com/avatar/6d12612270f160b9b015d603ed637f88cfc5edd78f2e287b29ac9ae22a71e8f1?d=identicon)[lalalili](/maintainers/lalalili)

---

Top Contributors

[![lalalili](https://avatars.githubusercontent.com/u/7522570?v=4)](https://github.com/lalalili "lalalili (17 commits)")

###  Code Quality

TestsPest

Static AnalysisPHPStan

Type Coverage Yes

### Embed Badge

![Health badge](/badges/lalalili-discount/health.svg)

```
[![Health](https://phpackages.com/badges/lalalili-discount/health.svg)](https://phpackages.com/packages/lalalili-discount)
```

###  Alternatives

[psalm/plugin-laravel

Psalm plugin for Laravel

3355.3M346](/packages/psalm-plugin-laravel)[renatomarinho/laravel-page-speed

Laravel Page Speed

2.5k1.7M10](/packages/renatomarinho-laravel-page-speed)[illuminate/pagination

The Illuminate Pagination package.

12234.1M1.0k](/packages/illuminate-pagination)[illuminate/pipeline

The Illuminate Pipeline package.

9349.2M282](/packages/illuminate-pipeline)[illuminate/redis

The Illuminate Redis package.

8314.6M376](/packages/illuminate-redis)[illuminate/cookie

The Illuminate Cookie package.

244.6M137](/packages/illuminate-cookie)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
