Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Promotion Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. Vanilo\Promotion\Models\Promotion — the aggregate root. A promotion has name, priority, validity window (start_at/end_at), and optional usage caps.
  2. Promotion::rules() and Promotion::actions() — polymorphic collections that decide when a promotion fires and what it does.
  3. Vanilo\Promotion\Models\Coupon — coupon codes belong to a promotion; applying a coupon is applying its parent promotion.
  4. The cart service provider wiring — that is where you'll see how promotion results land as adjustments on 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.

Implementation Patterns

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:

  • Automatic: rules only, evaluated on cart recalculation. Use these for "free shipping over €100" or "buy 3 get 1 free".
  • Coupon: rules + a 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.

Gotchas and Tips

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:

  • Custom PromotionRule subclasses for eligibility logic.
  • Custom PromotionAction subclasses for discount math.
  • Model swapping via the module config (models.promotion, models.coupon) if you need extra columns.
  • Events emitted around adjustment creation — listen rather than override the engine when you need audit logging.

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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource