How to Build a Custom "Advanced Promotions" Engine for Complex B2B Scenarios in 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, step-by-step snippets, and real performance tips 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, multiple customer groups, contract dates, company-specific rules.
- Integration requirements: tier prices, negotiated prices, volume discounts already in your price systems.
- Approval workflows: sales or legal approvals before a promotion goes live.
- Performance at scale: thousands of rules evaluated on quotes and orders.
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 price engines (tier price, negotiated price)
- 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/checkout.
- Integration Layer: hooks into price systems and totals calculation (plugins/observers).
- 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. This is 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 (workflow 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)
- customer group / company / contract
- time windows (start/end dates, business hours)
- minimum order value / 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: how to precompute rules
Rule compilation/indexing is critical for performance. Idea: transform rule rows 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 example 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 reindex 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 files 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 methods:
- getCompiledRulesForContext($storeId, $customerGroupId, $date)
- getRulesBySkuPointers(array $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 must be:
- Idempotent — running multiple times should not stack unexpected discounts
- Fast — avoid iterating thousands of rules on every evaluation
- Deterministic — clear priority and stop_processing semantics
Evaluation strategy:
- Collect candidate rules by pointers: SKU pointers, customer group 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 prices repeatedly
- Respect priority and stop_processing
Example evaluator pseudo-code (used in observer 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 price systems
Important: Magento stores may already have:
- Tier prices (catalog_product_entity_tier_price)
- Negotiated prices stored in external systems or ERP
- Company-specific catalogs
Make your engine cooperate with these by:
- Running promotion evaluation after negotiated price resolution (so promotions apply to final price or in the order you expect).
- Providing hooks to transform the base price that promotions compute from — e.g. basePrice = $this->priceResolver->getFinalPrice($product, $customer).
- Optionally allow promotions to target margin or markup instead of price (some B2B customers prefer % off margin).
Example plugin order:
- Price Resolver (negotiated price) — plugin modifies product price during quote item creation
- Promotion Evaluator — plugin/observer applies discounts based on compiled rules
- Totals Collectors — sum and persist discount values
Sample plugin xml (simplified) to run evaluator after price 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: price per unit depends on purchased quantity. A good approach 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
- Multiple levels: sales rep > sales manager > legal
- Each approval step recorded in promotion_approval.approvals as a JSON array of {level, approver_id, comment, ts, decision}
- Notifications: use message queue for async notification emails 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 tips
Admin UX matters. Provide:
- JSON editor for advanced users + form UI for common tasks
- Approval panel that shows current level and pending approvers
- Preview: a sandbox that runs the compiled rules against a sample quote so sales reps can preview the effect
- Bulk actions to enable/disable or submit multiple promotions for approval
Performance at scale (thousands of rules)
This is the meat. If you expect thousands of rules, you must optimize both storage and evaluation:
1) Pre-filter 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 related to SKUs in the quote.
- Customer group pointer: same idea for customer groups, company IDs.
2) Precompile to cheap-to-evaluate JSON
Compiled JSON should be 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+customerGroup. E.g. key: promotions:compiled:store:1:customer_group:4. Invalidate keys 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 message queue workers for non-critical tasks (rebuilds, notification emails, analytics). Only the minimal evaluation for checkout should be synchronous.
7) Optimize DB
Keep important columns indexed: rule_id, is_active, start_datetime, end_datetime. Avoid wide text scans on the request path.
8) Use PHP optimization
Keep code non-object heavy in the hot path. Use arrays and small helper functions. 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: unit tests and integration tests
Write tests for:
- Condition evaluation unit tests (edge cases like boundary quantities, empty SKUs)
- Integration tests that simulate a quote with negotiated prices 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 checkout
- Average evaluation time
- Cache hit ratio for compiled rules
- Approval turnaround time
Expose a small admin dashboard 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 order.
- 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 users can create active promotions.
Putting it all together — example workflow
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 checkout, evaluator fetches candidate rules by SKU pointers and applies the tiered discount
- Analytics show number of hits and realized discount value
Practical checklist before launching
- Implement SKU & customer group 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 checkout
- 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 users. Use the compiled+indexed approach to keep the runtime fast.
If you host on Magefine, make sure your Redis and message queue are sized properly and your environment has horizontal scaling for checkout nodes. Magefine’s managed hosting environments make it easy to add Redis/Elasticsearch and tune DBs for these patterns (and they already know Magento’s typical bottlenecks).
Appendix — Useful implementation 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 values (customer group, store id) in local variables
- Avoid creating lots of objects in loops — use arrays
- 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 pricing resolution
- Provide an approval workflow with audit trails
- Optimize the hot path with caching and reverse indices
If you 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 API endpoints.




