vanilo/promotion
Vanilo Promotion brings flexible discount and promotion rules to Vanilo e-commerce apps, with support for coupons, cart and item conditions, automatic promotions, and Laravel-friendly configuration.
Vanilo Promotion is the discounts/coupons module of the Vanilo e-commerce suite. It plugs into vanilo/cart and vanilo/order and works by attaching adjustments to cart/order items, so it is not a standalone pricing library — you need the cart stack for it to do anything useful.
Minimal setup:
composer require vanilo/promotion
php artisan vendor:publish --tag=concord # if not already published
php artisan migrate
The module auto-registers through Concord (config/concord.php → modules array). If you maintain a manual module list, add Vanilo\Promotion\Providers\ModuleServiceProvider::class and clear config cache.
Where to look first:
Vanilo\Promotion\Models\Promotion — the aggregate root. A promotion has name, priority, validity window (start_at/end_at), and optional usage caps.Promotion::rules() and Promotion::actions() — polymorphic collections that decide when a promotion fires and what it does.Vanilo\Promotion\Models\Coupon — coupon codes belong to a promotion; applying a coupon is applying its parent promotion.Cart and later persist to Order.First use case — a 10% coupon for carts over €50:
$promotion = Promotion::create([
'name' => 'Spring 10%',
'priority' => 10,
'start_at' => now(),
'end_at' => now()->addMonth(),
'usage_limit'=> 500,
]);
$promotion->rules()->create([
'type' => 'cart_total',
'config' => ['amount' => 5000], // minor units
]);
$promotion->actions()->create([
'type' => 'percentage_discount',
'config' => ['percent' => 10],
]);
$promotion->coupons()->create(['code' => 'SPRING10']);
Then apply the code to a cart through the cart API ($cart->addCoupon('SPRING10') in Vanilo 4, or the equivalent Coupon::apply($cart) helper in older lines) and re-fetch the cart to see the adjustment. If no adjustment appears, the rule/action type strings are wrong — check the registered keys in the module config, not the class names.
Start with one promotion, one coupon, and a single rule. Debug by dumping $cart->adjustments before and after applying.
Seed promotions, don't hardcode them. Treat promotions as data. A PromotionSeeder plus an admin CRUD screen is the normal production shape. Keep rule/action config as JSON arrays so you can add new rule types without migrations.
Rule + Action composition. The idiomatic pattern is: every promotion has all rules passing (AND semantics) before any action runs. If you need OR semantics, create two promotions. Compose actions to layer discounts — but remember multiple actions on a single promotion all run for each eligible item/order, so percentage + fixed amount actions will stack.
Automatic vs. coupon-gated promotions. Distinguish two pipelines:
Coupon record. Only runs after addCoupon(). Keep the coupon check inside the promotion engine (not your controller) so manual/bulk coupon application stays consistent.Coupon flow in controllers.
try {
$cart->addCoupon($request->input('code'));
} catch (\Vanilo\Promotion\Exceptions\InvalidCouponException $e) {
return back()->withErrors(['code' => $e->getMessage()]);
}
$cart->calculate(); // ensure adjustments are recomputed
Custom rules/actions. Extend the base rule/action class, implement the single validation or execution method, and register the key in the module config (promotion.rules / promotion.actions maps). This is the intended extension point — it keeps the polymorphic type column stable while letting you add business logic like "company VAT number present" or "cart contains SKU X".
Order persistence. The adjustment travels from Cart to Order when you create the order from a cart via the order module's createFromCart(). Do not re-run promotion logic on the order — rely on the persisted adjustments so totals are reproducible. If you need to re-evaluate post-checkout, build a separate reconciliation job; don't mutate order adjustments from controllers.
Testing. Use Vanilo\Cart\Tests\Factories and the promotion model factories in your own tests. A good smoke test: build a cart with a known line item total, apply a coupon, assert cart->total() equals the expected discounted total to the minor unit.
Money is in minor units. All config amounts and adjustment amounts are integers (cents). Mixing floats in rule configs silently produces wrong comparisons; cast on input.
Rules are AND, not OR. The single most common bug is expecting two rules to mean "either/or". Split into separate promotions with distinct priorities.
Priority is only for ordering, not exclusivity. Two promotions with the same actions will both apply. If you need "best deal wins", implement a custom selector or filter in a service and enable only the chosen promotion before recalculating.
Timezone traps on validity windows. start_at/end_at are compared against now() in the app timezone. For scheduled campaigns, store UTC and set config('app.timezone') accordingly — a promotion "ending at midnight" will otherwise stay live until 02:00 for CET users.
Usage limits race under concurrency. The usage_limit counter is checked and incremented at different points. With parallel checkouts you can oversell coupons. For serious volume, wrap coupon redemption in a DB transaction with lockForUpdate() on the promotion/coupon row, or add an atomic counter column updated with increment() plus a pre-check.
Cart recalculation is mandatory after coupon changes. addCoupon()/removeCoupon() may not recalc totals in all versions; call $cart->calculate() explicitly and re-fetch relations before showing the cart to the user.
Adjustments vs. line items. Discounts show up as adjustments attached to either the whole cart or a specific cart item, depending on the action. Free-shipping actions usually land as a shipping adjustment, not a subtotal one — check adjustment->type when rendering the cart summary.
Config caching bites. After adding a custom rule/action class, run php artisan config:clear and php artisan cache:clear. Concord caches module metadata; a stale cache means your custom type string resolves to null and the promotion silently does nothing.
Migration ordering. The promotion migrations must run after cart/order migrations. If you're integrating into an existing app, check php artisan migrate:status — a partially-migrated state leaves you with promotions but no promotion_rules, and the engine fails at runtime with a null-relationship error rather than a clear message.
Extension points to lean on:
PromotionRule subclasses for eligibility logic.PromotionAction subclasses for discount math.models.promotion, models.coupon) if you need extra columns.Debugging recipe. When a promotion "doesn't fire": (1) dump $promotion->rules->count() and each rule's config; (2) confirm the validity window in the app timezone; (3) confirm the coupon row exists and is enabled; (4) log inside the rule's validation method to see the values it compares; (5) verify type strings match the registered keys exactly (snake_case, not class names).
Stars and maintenance. This is a low-traffic module — pin the major version in composer.json and read the CHANGELOG before upgrading Vanilo, since adjustments and coupon APIs have shifted between majors (3, 4, 5). Write integration tests around your cart totals; unit tests alone won't catch adjustment regressions.
How can I help you explore Laravel packages today?