lalalili / discount
Config-driven discount and coupon kernel for Laravel projects.
Requires
- php: ^8.4
- illuminate/support: ^12.0
Requires (Dev)
- larastan/larastan: ^3.0
- phpstan/phpstan: ^2.1
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)
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.4 - Laravel
^12.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
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.1"
}
}
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.1"
}
}
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.
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
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 in
[21,22], skip and log warning (for examplelegacy_coupon_type_detected).
Versioning Note
Current package composer.json version is 2.1.0.
This release is a minor update from 2.0.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). - Keep issuing flows in app adapters (
register,birthday,first order). - Add runtime skip policy for deprecated coupon types.
- Run smoke tests for checkout pricing and coupon issuance.