lalalili / discount
Config-driven discount and coupon kernel for Laravel projects.
Requires
- php: ^8.3
- illuminate/support: ^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^3.0
- pestphp/pest: ^4.0
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^12.0
README
Config-driven discount and coupon kernel for Laravel projects.
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
- PHP
^8.3 - Laravel components
^12.0|^13.0
Public Interfaces
The package API is stable at interface level:
DiscountEngineInterface::price(ProductContext $product, PromotionSet $promotions): PriceResultCartPromotionEngineInterface::apply(CartContext $cart, PromotionSet $promotions): CartAdjustmentResultCouponEligibilityInterface::validate(CouponContext $coupon, CartContext $cart, UserContext $user): EligibilityResultCouponCodeGeneratorInterface::generate(CodeContext $context): stringCouponDiscountEngineInterface::discount(float $orderTotal, CouponContext $coupon): CouponDiscountResultCouponApplicationServiceInterface::validate(CouponKind $kind, string $code, CartContext $cart, UserContext $user): CouponValidationResult
Core Types
Enums
CouponAmountMode:auto | fixed | rateCouponKind:member | promotion
DTOs
CouponDatacode,kind,scope,triggerAmount,amount,amountMode,status,limitQty,leftQty,userId,attributes
CouponDiscountResultvalid,discount,finalTotal,reason,reasonCode
CouponValidationResulteligible,coupon,discount,finalTotal,reason,reasonCode,pricingTrace
CouponConditionPayloadtype,target,value,order,attributes
PricingTrace- list wrapper for
PricingTraceEntry
- list wrapper for
PricingTraceEntry- stable array fields:
stage,source,status,scope,kind,code,id,amount,final_total,reason_code,reason,metadata
- stable array fields:
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,couponstatus:applied,skipped,failed,issued,restoredstage: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.
Discount\Kernel\Support\PricingTraceFormatter provides stable helpers for app adapters:
normalize()convertsPricingTrace,PricingTraceEntry, single-entry arrays, and entry lists into a list of arrays.mergeLatestByIdentity()replaces duplicate entries bystage/source/kind/id-or-codeand 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(),
],
]
Discount\Kernel\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 Discount\Kernel\Enums\CouponKind; use Discount\Kernel\Support\CouponConditionPayloadFactory; $payload = app(CouponConditionPayloadFactory::class)->make( CouponKind::Promotion, 50, $couponApplyEntry, ); $conditionArgs = $payload->toArray([ 'name' => __('cruds.coupon.promotion'), ]);
Stable payload values:
type:member_couponorpromotion_coupontarget:totalorder:10for member coupon,11for promotion couponvalue: negative numeric discount amountattributes.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
CouponContext now supports:
scopetriggerAmountamountamountMode(CouponAmountMode|string|null, optional, defaultauto)
Install
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)
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
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
To use CouponApplicationServiceInterface, your app must bind CouponRepositoryInterface:
use Discount\Kernel\Contracts\CouponRepositoryInterface; use App\Services\Events\Support\EloquentCouponRepository; $this->app->singleton(CouponRepositoryInterface::class, EloquentCouponRepository::class);
Config-Driven Model
config/discount.php sections:
event.type_role_mapevent.prioritiescoupon.scope_mapcoupon.code.prefixescoupon.code.templatescoupon.code.tokenscart.rolescart.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
Product pricing (79折)
use Discount\Kernel\Contexts\ProductContext; use Discount\Kernel\Contexts\PromotionContext; use Discount\Kernel\Contexts\PromotionSet; use Discount\Kernel\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)
use Discount\Kernel\Contexts\CouponContext; use Discount\Kernel\Engines\DefaultCouponDiscountEngine; use Discount\Kernel\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)
use Discount\Kernel\Contexts\CartContext; use Discount\Kernel\Contexts\UserContext; use Discount\Kernel\Contracts\CouponApplicationServiceInterface; use Discount\Kernel\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
use Discount\Kernel\Contexts\CodeContext; use Discount\Kernel\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
use Discount\Kernel\Contexts\CartContext; use Discount\Kernel\Contexts\PromotionContext; use Discount\Kernel\Contexts\PromotionSet; use Discount\Kernel\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
CouponValidationResult::reasonCode and CouponDiscountResult::reasonCode are stable public contract fields.
COUPON_NOT_FOUNDAUTH_REQUIREDCOUPON_ALREADY_USEDCOUPON_OUT_OF_STOCKDISCOUNT_INVALIDELIGIBILITY_FAILED
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_metexclusive_conflictgift_unresolvedgift_out_of_stocknot_selected
Responsibilities:
lalalili/discountcomputes 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/laravelshoppingcarttriggers refresh throughbefore_totalspipelines and stores observability data inCartPipelineResult::metadataandsnapshot()['pipelines'].- Apps may skip refresh when a locally computed
promotion_refresh_signatureis unchanged, but checkout entry should force one final refresh before payment.
Recommended pipeline metadata:
promotion_versionrefresh_reasonduration_mspromotion_refresh_signatureapplied_countskipped_count
Coupon Flows (App Adapter Layer)
Keep these flows in your application and call kernel engines:
PROMOTION (2): admin-created promo coupon flowREGISTER (11): issue after user registrationBIRTHDAY (12): monthly birthday schedulerFIRST_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=CouponForFirstOrderand usable/used status already exists.
Recommended deprecated runtime guard:
- If coupon type is
22, skip and log warning (for examplelegacy_coupon_type_detected). - Keep
21available if your app still supports LINE binding coupon issuance.
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
Inside package directory:
composer install composer analyse
Quick Onboarding for Another Project
- Install
lalalili/discountvia VCS + tag. - Publish or create
config/discount.php. - Map local event/coupon enum values in config.
- Bind
CouponRepositoryInterfaceto your adapter implementation. - Set
cart.gift_resolver(ornullif gift not used). - Build product/cart/user contexts from your local models.
- Wrap
CouponConditionPayloadFactoryin a local cart adapter that adds the condition name and instantiates your cart condition class. - Add a local reason-message resolver for API/UI copy.
- Keep order lifecycle in the app layer: member coupon deactivation, promotion inventory decrement, and cancel-order restore.
- Keep issuing flows in app adapters (
register,birthday,first order). - Add runtime skip policy for deprecated coupon types if your data still contains legacy coupon types.
- Run smoke tests for checkout pricing, coupon application, order creation, inventory decrement, cancel restore, and coupon issuance.