Comment créer un moteur de promotions avancées personnalisé pour des scénarios B2B complexes dans Magento 2
Let’s build an Advanced Promotions engine for Magento 2 that actually works in complex B2B scenarios. I’ll talk like I’m standing next to you at the desk — relaxed, practical, and with code you can copy-paste and adapt. No fluff, just clear architecture, étape-by-étape snippets, and real performance conseils so this thing can run thousands of rules without melting the database.
Why you need a custom promotions engine for B2B
B2B promotions aren’t the same as B2C flash sales. You’ll face:
- Conditional complexity: quantities, mulconseille groupe de clientss, contract dates, company-specific rules.
- Integration prérequis: tier prixs, negotiated prixs, volume discounts already in your prix systems.
- Approval flux de travails: sales or legal approvals before a promotion goes live.
- Performance at scale: thousands of rules evaluated on quotes and commandes.
Magento’s native Cart Price Rules work for many stores, but for advanced B2B scenarios you want a system built to:
- Store complex conditions
- Precompile and index rules for fast evaluation
- Integrate with B2B prix engines (tier prix, negotiated prix)
- Support multi-level approvals and auditing
- Scale horizontally and cache aggressively
High-level Architecture
Here’s a simple block diagram you should keep in mind:
- Admin UI: create/edit promotions, see approval status.
- Rule Store (DB): canonical rule format + metadata.
- Rule Compiler / Indexer: transforms rules into an optimized form (JSON AST, DB indices).
- Evaluation Engine: fast in-memory evaluator used during quote/paiement.
- Integration Layer: hooks into prix systems and totals calculation (plugins/observateurs).
- Workflow Engine: approval states, notifications, audit logs.
- Monitoring & Metrics: rule counts, evaluation latency, hit rates.
Core entities and DB layout (practical)
Let’s design a compact relational layout for rule storage. C'est the minimal set you’ll need:
- promotion_rule (main table)
- promotion_condition (normalized conditions)
- promotion_action (what to apply)
- promotion_compiled (indexed/compiled representation)
- promotion_approval (flux de travail states, approver, history)
Example simplified schema for promotion_rule (install schema SQL):
CREATE TABLE promotion_rule (
rule_id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
is_active TINYINT(1) DEFAULT 0,
start_datetime DATETIME DEFAULT NULL,
end_datetime DATETIME DEFAULT NULL,
customer_groups TEXT DEFAULT NULL, -- json array
priority INT DEFAULT 0,
stop_processing TINYINT(1) DEFAULT 0,
scope VARCHAR(32) DEFAULT 'global', -- store, website, global
created_at DATETIME,
updated_at DATETIME
) ENGINE=InnoDB;
CREATE TABLE promotion_condition (
condition_id INT AUTO_INCREMENT PRIMARY KEY,
rule_id INT NOT NULL,
type VARCHAR(255) NOT NULL, -- e.g. 'product_qty_between'
payload JSON, -- details like min_qty, max_qty, sku_list etc
CONSTRAINT fk_rule FOREIGN KEY (rule_id) REFERENCES promotion_rule(rule_id) ON DELETE CASCADE
) ENGINE=InnoDB;
CREATE TABLE promotion_action (
action_id INT AUTO_INCREMENT PRIMARY KEY,
rule_id INT NOT NULL,
type VARCHAR(255) NOT NULL, -- e.g. 'percent_off', 'fixed_price', 'free_shipping'
payload JSON,
CONSTRAINT fk_rule_action FOREIGN KEY (rule_id) REFERENCES promotion_rule(rule_id) ON DELETE CASCADE
) ENGINE=InnoDB;
CREATE TABLE promotion_compiled (
compiled_id INT AUTO_INCREMENT PRIMARY KEY,
rule_id INT NOT NULL,
compiled_json LONGTEXT, -- optimized evaluation form
last_compiled_at DATETIME,
CONSTRAINT fk_rule_compiled FOREIGN KEY (rule_id) REFERENCES promotion_rule(rule_id) ON DELETE CASCADE
) ENGINE=InnoDB;
CREATE TABLE promotion_approval (
approval_id INT AUTO_INCREMENT PRIMARY KEY,
rule_id INT NOT NULL,
status VARCHAR(64), -- draft, pending, approved, rejected
current_level INT DEFAULT 0,
approvals JSON, -- array of approver decisions
updated_at DATETIME,
CONSTRAINT fk_rule_approval FOREIGN KEY (rule_id) REFERENCES promotion_rule(rule_id) ON DELETE CASCADE
) ENGINE=InnoDB;
Rule modelling: conditions and actions
Keep rules as small composable conditions. For B2B you’ll commonly need:
- product SKUs or categories
- quantity ranges (total item qty, per-sku qty)
- groupe de clients / company / contract
- time windows (start/end dates, entreprise hours)
- minimum commande valeur / weight
- stacking rules (exclusive vs combinable)
Example JSON payload for a condition:
{
"type": "qty_range",
"target": "sku",
"skus": ["ABC-123", "DEF-456"],
"min_qty": 10,
"max_qty": 100
}
Example action payload for a volumetric discount:
{
"type": "tiered_percent",
"tiers": [
{"min_qty": 1, "percent": 0},
{"min_qty": 10, "percent": 5},
{"min_qty": 50, "percent": 10},
{"min_qty": 200, "percent": 15}
],
"apply_to": "line_item",
"stack_with": false
}
Compiler / Indexer: comment precompute rules
Rule compilation/indexation is critical for performance. Idea: transform rule lignes into compiled JSON that's cheap to evaluate in PHP (or even in Redis Lua scripts).
What the compiler does:
- Validate and normalize payloads
- Flatten nested conditions into a small Expression Tree (AST)
- Precompute simple matches (for exemple store sku -> rule pointers)
- Store compiled JSON into promotion_compiled
Example compiled format:
{
"rule_id": 101,
"priority": 10,
"conditions": [
{"type": "sku_in", "skus": ["ABC-123","DEF-456"]},
{"type": "qty_between", "min": 10, "max": 100},
{"type": "customer_group_in", "groups": [3,4]}
],
"actions": [
{"type":"tiered_percent","tiers":[{"min":10,"percent":5},...]}
],
"meta": {"start": "2025-01-01T00:00:00Z", "end": null}
}
Indexer triggers:
- On rule save: recompile affected rules
- Scheduled full réindexer for safety (CRON)
- On product changes (if SKU/category rules exist): optionally touch related rules
Magento module skeleton (quick start)
Let me show the minimal fichiers to register a module. Put these in app/code/Vendor/PromotionsEngine.
app/code/Vendor/PromotionsEngine/registration.php
<?php
use Magento\Framework\Component\ComponentRegistrar;
ComponentRegistrar::register(ComponentRegistrar::MODULE, 'Vendor_PromotionsEngine', __DIR__);
app/code/Vendor/PromotionsEngine/etc/module.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
<module name="Vendor_PromotionsEngine" setup_version="1.0.0" />
</config>
Service layer: RuleRepository + RuleCompiler
Keep a service class that exposes fast méthodes:
- getCompiledRulesForContext($storeId, $clientGroupId, $date)
- getRulesBySkuPointers(tableau $skus)
- compileRule($ruleId)
Example pseudocode for a RuleCompiler::compile($rule):
public function compile($rule)
{
// validate structure
$conditions = $this->normalizeConditions($rule->getConditions());
// flatten simple SKU matches to speed lookup
$skus = $this->extractSkus($conditions);
$compiled = [
'rule_id' => $rule->getId(),
'priority' => (int) $rule->getPriority(),
'conditions' => $conditions,
'sku_pointers' => array_values($skus),
'actions' => $this->normalizeActions($rule->getActions()),
'meta' => ['start' => $rule->getStartDatetime(), 'end' => $rule->getEndDatetime()]
];
// persist as JSON in promotion_compiled
$this->compiledRepository->save($rule->getId(), json_encode($compiled));
return $compiled;
}
Evaluation engine — keep it tiny and fast
The engine runs during quote totals collection. It doit être:
- Idempotent — running mulconseille times should not stack unexpected discounts
- Fast — avoid itenote thousands of rules on every evaluation
- Deterministic — clear priority and stop_processing semantics
Evaluation stratégie:
- Collect candidate rules by pointers: SKU pointers, groupe de clients pointers, global pointers
- Filter by time window and active flag
- Evaluate conditions against quote (line items aggregated by SKU)
- Apply actions using a compact action runner that mutates quote items (or stores promo totals) — avoid recalculating prixs repeatedly
- Respect priority and stop_processing
Example evaluator pseudo-code (used in observateur or plugin):
// run during quote total collection
$candidateRules = $this->ruleService->getCandidateRulesForQuote($quote);
// sort by priority asc (or desc depending on your policy)
usort($candidateRules, function($a,$b){ return $b['priority'] - $a['priority']; });
foreach ($candidateRules as $compiled) {
if (!$this->matchesConditions($compiled['conditions'], $quote)) {
continue;
}
// apply actions
foreach ($compiled['actions'] as $action) {
$this->applyAction($action, $quote);
}
if (!empty($compiled['meta']['stop_processing'])) {
break; // stop other rules
}
}
// save modified quote totals
Integration with existing B2B prix systems
Important: Magento stores may already have:
- Tier prixs (catalog_product_entity_tier_prix)
- Negotiated prixs stored in external systems or ERP
- Company-specific catalogs
Make your engine cooperate with these by:
- Running promotion evaluation after negotiated prix resolution (so promotions apply to final prix or in the commande you expect).
- Providing hooks to transform the base prix that promotions compute from — e.g. basePrice = $this->prixResolver->getFinalPrice($product, $client).
- Optionally allow promotions to target margin or markup au lieu de prix (some B2B clients prefer % off margin).
Example plugin commande:
- Price Resolver (negotiated prix) — plugin modifies product prix during quote item creation
- Promotion Evaluator — plugin/observateur applies discounts basé sur compiled rules
- Totals Collectors — sum and persist discount valeurs
Sample plugin xml (simplified) to run evaluator after prix resolution:
<type name="Magento\Quote\Model\Quote\Item\PriceCalculator">
<plugin name="vendor_promotions_evaluator" type="Vendor\PromotionsEngine\Plugin\PriceEvaluator" />
</type>
Handling volumetric discounts
Volumetric discounts are typical in B2B: prix per unit dépend de purchased quantity. A good approche is to keep volumes as tier actions (as shown earlier) and apply tiers on aggregated SKU quantities in quote.
Example simplified tier application logic:
foreach ($quote->getAllVisibleItems() as $item) {
$sku = $item->getSku();
$qty = $aggregated[$sku];
$tier = $this->findBestTier($tiers, $qty);
if ($tier) {
$discount = $item->getPrice() * ($tier['percent'] / 100);
$item->setDiscountAmount($discount * $item->getQty());
$item->setBaseDiscountAmount($discount * $item->getQty());
}
}
Workflows: multi-level approvals and tracking
In B2B you rarely want promotions to go live without approvals. Keep a simple but robust approval engine:
- Promotion has approval flow: draft > pending > approved > active
- Mulconseille levels: sales rep > sales manager > legal
- Each approval étape recorded in promotion_approval.approvals as a JSON tableau of {level, approver_id, comment, ts, decision}
- Notifications: use fichier de messages for async notification e-mails so admin actions don’t block UI
- Audit trail: store diffs of rule before/after for legal traceability
Example approval transition flow (pseudo):
public function submitForApproval($ruleId, $userId)
{
$approval = $this->approvalRepository->getByRuleId($ruleId);
$approval->setStatus('pending');
$approval->setCurrentLevel(1);
$approval->addEntry(['level'=>1,'user_id'=>$userId,'decision'=>'submitted','ts'=>time()]);
$this->approvalRepository->save($approval);
// publish message for notification
$this->messageQueue->publish('promotion_approval_requested', ['rule_id'=>$ruleId]);
}
public function resolveApproval($ruleId, $level, $decision, $approverId)
{
$approval = $this->approvalRepository->getByRuleId($ruleId);
$approval->addEntry(['level'=>$level,'user_id'=>$approverId,'decision'=>$decision,'ts'=>time()]);
if ($decision === 'approved' && $level === $approval->getMaxLevel()) {
$approval->setStatus('approved');
// activate the promotion (change is_active and compile)
} elseif ($decision === 'rejected') {
$approval->setStatus('rejected');
} else {
$approval->setCurrentLevel($level + 1); // move to next approver
}
$this->approvalRepository->save($approval);
}
Admin UI conseils
Admin UX matters. Provide:
- JSON editor for advanced utilisateurs + form UI for common tasks
- Approval panel that shows current level and pending approvers
- Pavis: a sandbox that runs the compiled rules against a sample quote so sales reps can pavis the effect
- Bulk actions to enable/disable or submit mulconseille promotions for approval
Performance at scale (thousands of rules)
C'est the meat. Si vous expect thousands of rules, you must optimize both storage and evaluation:
1) Pre-filtre aggressively
Don’t evaluate everything. Filter rules by cheap checks first:
- Active flag, time window, store scope — do these in SQL when fetching compiled candidates.
- SKU pointers: store a reverse index table sku -> rule_id so you only fetch rules lié à SKUs in the quote.
- Customer group pointer: same idea for groupe de clientss, company IDs.
2) Precompile to cheap-to-evaluate JSON
Compiled JSON devrait être directly consumable by your evaluator; avoid heavy parsing. Consider storing compiled JSON in Redis for super-fast reads if you run many web nodes.
3) Use caching layers
Redis can store compiled rules by store+clientGroup. E.g. clé: promotions:compiled:store:1:client_group:4. Invalidate clés on rule changes.
4) Use index tables
Reverse index tables accelerate candidate selection:
CREATE TABLE promotion_sku_index (
sku VARCHAR(64) NOT NULL,
rule_id INT NOT NULL,
PRIMARY KEY (sku, rule_id)
) ENGINE=InnoDB;
-- Populate on compilation and update as rules change
5) Batch evaluation
Evaluate per-SKU aggregated data rather than per-item. If a quote has 50 items but only 7 unique SKUs, evaluate on 7 buckets.
6) Move heavy work off request path
Use fichier de messages workers for non-critical tasks (rebuilds, notification e-mails, analytics). Only the minimal evaluation for paiement devrait être synchronous.
7) Optimize DB
Keep important colonnes indexed: rule_id, is_active, start_datetime, end_datetime. Avoid wide text scans on the request path.
8) Use PHP optimization
Keep code non-objet heavy in the hot path. Use tableaus and small helper fonctions. Avoid heavy DI in the evaluator; inject minimal collaborators.
9) Measure and iterate
Track metrics: how many rules evaluated per quote, evaluation time, cache hit rate. Add tracing (X-Ray, New Relic) and tune accordingly.
Example: hooking the evaluator into totals collector
Use a totals collector to apply the promotions when totals are calculated. Here’s a skeleton collector that calls our evaluator.
namespace Vendor\PromotionsEngine\Model\Quote\Totals;
class PromotionCollector extends \Magento\Quote\Model\Quote\Address\Total\AbstractTotal
{
protected $evaluator;
public function __construct(Evaluator $evaluator)
{
$this->evaluator = $evaluator;
}
public function collect(\Magento\Quote\Model\Quote $quote, \Magento\Quote\Model\Quote\Address\Total $total)
{
parent::collect($quote, $total);
// run the evaluator which modifies items and sets discount amounts
$this->evaluator->evaluate($quote);
// sum discounts
$discountAmount = 0;
foreach ($quote->getAllVisibleItems() as $item) {
$discountAmount += $item->getDiscountAmount();
}
$total->setDiscountAmount($discountAmount);
$total->setGrandTotal($total->getGrandTotal() - $discountAmount);
return $this;
}
}
Testing: test unitaires and test d'intégrations
Write tests for:
- Condition evaluation test unitaires (edge cases like boundary quantities, empty SKUs)
- Integration tests that simulate a quote with negotiated prixs and ensure the promotion applies correctly
- Approval flows (submit, approve, reject) including permission checks
- Performance tests: synthetic loads with thousands of rules to measure latency
Monitoring and analytics
Track:
- Rules evaluated per paiement
- Average evaluation time
- Cache hit ratio for compiled rules
- Approval turnaround time
Expose a small admin tableau de bord with these KPIs and a list of the top rules by hits. That helps sales and ops understand which promotions are actually used.
Edge cases & gotchas
- Rounding: decide if discounts are rounded per item or per commande.
- Combinability: document and test how promotions stack. If two promotions both give a percentage off, ensure your logic defines which applies and whether compounding occurs.
- Performance regressions: changes in admin UI that allow complex ad-hoc rules may generate unoptimized compiled forms — validate compilation complexity limits.
- Security: protect admin endpoints so only authorized utilisateurs can create active promotions.
Putting it all together — exemple flux de travail
Let’s say a sales rep wants a promotion:
- Create rule: "SKU ABC-123, buy 50+, 10% off, start next Monday" (saved as draft)
- Submit for approval: rule moves to pending level 1
- Sales manager approves (level 2). The approval service marks it approved and triggers the compiler
- Compiler stores compiled JSON and updates SKU index table and Redis cache
- On next paiement, evaluator fetches candidate rules by SKU pointers and applies the tiered discount
- Analytics show number of hits and realized discount valeur
Practical checklist before launching
- Implement SKU & groupe de clients reverse indexes
- Implement a safe compile path and fallback to disabled if compile fails
- Protect evaluation: time-box loops and fail-safe to avoid blocking paiement
- Setup Redis for compiled cache and MQ for notifications
- Run load tests with realistic rules (mix of time-bound, sku-specific, and global)
- Document the precedence rules (priority, stop_processing)
Final notes — pragmatic choices
Building an Advanced Promotions engine is an investment: you gain flexibility and control, but you must own complexity and performance. Start small (support a few condition types and tiered actions), iterate, and expose an advanced JSON editor for power utilisateurs. Use the compiled+indexed approche to keep the runtime fast.
Si vous host on Magefine, make sure your Redis and fichier de messages are sized properly and your environment has horizontal scaling for paiement nodes. Magefine’s managed hosting environments make it easy to add Redis/Elasticrecherche and tune DBs for these patterns (and they already know Magento’s typical bottlenecks).
Appendix — Useful implémentation patterns and code snippets
1) Quick Redis caching pattern
// key: promotions:compiled:store:1:customer_group:4
$cacheKey = sprintf('promotions:compiled:store:%s:group:%s', $storeId, $customerGroupId);
$compiled = $this->redis->get($cacheKey);
if ($compiled === null) {
$compiled = $this->compiledRepo->getCompiledJsonForContext($storeId, $customerGroupId);
$this->redis->set($cacheKey, $compiled, ['ex' => 3600]); // ttl 1h
}
$rules = json_decode($compiled, true);
2) Sample sku -> rule index update during compilation
public function updateSkuIndex($ruleId, array $skus)
{
// delete previous pointers then insert new ones in a transaction
$this->connection->beginTransaction();
$this->connection->delete('promotion_sku_index', ['rule_id = ?' => $ruleId]);
$rows = [];
foreach ($skus as $sku) {
$rows[] = ['sku' => $sku, 'rule_id' => $ruleId];
}
if (!empty($rows)) {
$this->connection->insertMultiple('promotion_sku_index', $rows);
}
$this->connection->commit();
}
3) Quick evaluator micro-optimizations
- Cache frequently used valeurs (groupe de clients, store id) in local variables
- Avoid creating lots of objets in loops — use tableaus
- Short-circuit checks: time window and active flag first
- Prefer for-loops over foreach for micro gains on hot loops
Wrap-up
This isn’t a copy of Magento's Cart Price Rules — it’s built for B2B behavior: predictable, auditable, and scalable. Focus on:
- Compile & index rules
- Integrate properly with your B2B tarification resolution
- Provide an approval flux de travail with audit trails
- Optimize the hot path with caching and reverse indices
Si vous want, I can:
- Sketch the exact Magento DI configuration and collectors to drop into an app/code module
- Produce a complete sample module with installer schema and basic admin UI for creating a rule
- Design a load test scenario that mimics your current catalog and expected rule complexity
Tell me which follow-up you want and I’ll produce the next batch of code for you — e.g. a full RuleCompiler class or the approval REST points d'accès API.