Magento 2 Cart Price Rules: Add a Custom Condition
Cart price rules cover a lot out of the box: subtotal, quantity, weight, shipping method, payment method, destination, and product attributes inside the cart. Sooner or later marketing asks for something they do not cover, like "10% off for customers on their first order" or "free shipping if the customer has a company account". You do not need a promotions extension for that. Magento lets you add your own condition type to the rule editor, and it behaves like the core ones.
This article shows how to add a custom condition to cart price rules in Magento Open Source and Adobe Commerce 2.4.8, and how to keep it fast, because rule conditions run more often than most people expect.
Try the built in options first
Before writing code, check two things.
Product attributes. If the rule depends on a product attribute, set "Use for Promo Rule Conditions" to Yes in the attribute's storefront properties. The attribute then appears in the "Product attribute combination" and "Products subselection" conditions, and in the Actions tab. No code needed. The product attributes guide covers the other flags worth checking.
Customer segments. In Adobe Commerce, customer segments can be used as a condition in cart price rules, and segments already cover order history, customer attributes and more. That feature is Adobe Commerce only. Magento Open Source does not have segments, which is where a custom condition helps most.
How conditions are built
The Conditions tab of a cart price rule is a tree. The root is Magento\SalesRule\Model\Rule\Condition\Combine. Its getNewChildSelectOptions() method builds the "Add condition" dropdown from the address conditions, product conditions and combines. Before returning, it dispatches the salesrule_rule_condition_combine event with a DataObject called additional. Whatever you put in its conditions key is merged into the dropdown.
When the rule is saved, the tree is stored as JSON in the conditions_serialized column. Each node keeps its PHP class name in a type key. When the rule is validated, Magento rebuilds the tree from that JSON and calls validate() on each node with the quote address as the model.
So a custom condition needs two things: an observer that adds it to the dropdown, and a condition class that knows how to render and validate itself.
The observer
The dropdown is only built in the admin, so register the observer in etc/adminhtml/events.xml:
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Event/etc/events.xsd">
<event name="salesrule_rule_condition_combine">
<observer name="vendor_rules_add_order_count" instance="Vendor\Rules\Observer\AddOrderCountCondition"/>
</event>
</config>
<?php
declare(strict_types=1);
namespace Vendor\Rules\Observer;
use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
use Vendor\Rules\Model\Rule\Condition\OrderCount;
class AddOrderCountCondition implements ObserverInterface
{
public function execute(Observer $observer): void
{
$additional = $observer->getEvent()->getData('additional');
$conditions = (array) $additional->getConditions();
$conditions[] = [
'label' => __('Customer history'),
'value' => [
[
'value' => OrderCount::class . '|order_count',
'label' => __('Number of previous orders'),
],
],
];
$additional->setConditions($conditions);
}
}
Reading the existing conditions first matters. Other modules listen to the same event, and overwriting the array removes their options from the dropdown. The value format Class|attribute is the same one the core address conditions use.
The condition class
<?php
declare(strict_types=1);
namespace Vendor\Rules\Model\Rule\Condition;
use Magento\Framework\Model\AbstractModel;
use Magento\Rule\Model\Condition\AbstractCondition;
use Magento\Rule\Model\Condition\Context;
use Magento\Sales\Model\Order;
use Magento\Sales\Model\ResourceModel\Order\CollectionFactory;
class OrderCount extends AbstractCondition
{
private array $countCache = [];
public function __construct(
Context $context,
private readonly CollectionFactory $orderCollectionFactory,
array $data = []
) {
parent::__construct($context, $data);
}
public function loadAttributeOptions()
{
$this->setAttributeOption([
'order_count' => __('Number of previous orders'),
]);
return $this;
}
public function getInputType()
{
return 'numeric';
}
public function getValueElementType()
{
return 'text';
}
public function validate(AbstractModel $model)
{
$customerId = (int) $model->getQuote()?->getCustomerId();
return $this->validateAttribute($this->getOrderCount($customerId));
}
private function getOrderCount(int $customerId): int
{
if ($customerId === 0) {
return 0;
}
if (!isset($this->countCache[$customerId])) {
$collection = $this->orderCollectionFactory->create();
$collection->addFieldToFilter('customer_id', $customerId);
$collection->addFieldToFilter('state', ['neq' => Order::STATE_CANCELED]);
$this->countCache[$customerId] = $collection->getSize();
}
return $this->countCache[$customerId];
}
}
Because the input type is numeric, the admin offers the numeric operators: equals, is not, greater than, less than and the "or" variants. A "first order" rule is then "Number of previous orders equals 0". validateAttribute() applies the operator and value chosen in the admin, so the class only has to return the right number.
Guests always have a count of zero in this version. If guest orders should count, look them up by email, but be aware the quote email is often empty until the customer reaches checkout, so the rule can change between cart and checkout. Spell out this behavior to the merchant.
Other conditions built the same way
Once the first condition works, the pattern repeats with a different lookup inside validate(). Some I find useful in practice:
- Lifetime value of the customer, as a numeric condition on the sum of previous order totals.
- Days since the last order, for win back campaigns.
- A customer attribute, like a B2B account type, as a select condition. Override
getValueSelectOptions()to fill the dropdown and returnselectfrom both input type methods. - The current time window, for promotions that only run in certain hours. Use the store timezone from
Magento\Framework\Stdlib\DateTime\TimezoneInterface, not the server clock.
Performance
Conditions run every time totals are collected, and that happens many times per request in checkout. A slow validate() adds up fast. A few rules:
- Memoize per request. The class above keeps counts in an array property. Each rule has its own condition object, so when several rules use the same lookup, move the cache into a shared service injected into the condition.
- Use cheap queries.
getSize()runs aCOUNTquery instead of loading orders. Make sure the columns you filter on are indexed.customer_idonsales_orderis. - Never call external APIs. If the data lives in another system, sync it to a customer attribute or a local table and read it from there.
- Scope the rule. Magento skips validation of rules that are not active for the website and customer group, so setting those fields correctly avoids running your condition at all.
To see the real cost, profile a checkout with your rule enabled. Profiling with Blackfire shows how.
Adding an attribute to the existing address condition
Sometimes the value you need is already on the quote address, or can be set there by your code before rules run. Then a plugin on Magento\SalesRule\Model\Rule\Condition\Address::loadAttributeOptions() is enough. After calling the original method, read getAttributeOption(), add your code and label, and set it back. The core validate() of the address condition reads the attribute from the address data. You will also want plugins for getInputType() and getValueElementType() if the default string input does not fit.
This keeps the option in the same "Cart Attribute" group the merchant already knows, but it only works if the data is on the address before discount collection runs.
Deployment and removal
Because rules store the class name in the database, two things need care:
- Renaming the class breaks every saved rule that uses it. If you must rename, write a data patch that rewrites the
typevalues insideconditions_serializedandactions_serialized. - Disabling the module while rules still use the condition causes errors when those rules load. Remove the condition from all rules first. The article on removing a module safely covers this kind of cleanup.
Write an integration test that creates a rule with your condition, builds a quote for a customer with and without previous orders, and checks that the discount applies only when it should. It catches mistakes in the operator logic quickly.
FAQ
Can I use a custom condition in the Actions tab of a cart price rule?
The Actions tab uses product conditions, which validate each cart item. A custom item level condition is built in a similar way, but on the product combine condition instead of the address combine.
Why does my condition not appear in the dropdown?
Check that the observer is registered for salesrule_rule_condition_combine in the adminhtml or global area, that it keeps the existing conditions, and clean the cache after deploying.
Does a custom condition work with coupon codes?
Yes. The coupon only decides whether the rule is considered. Your condition is validated like any other once the coupon is applied.
Is a custom condition slow?
Only if validate is slow. It runs on every totals collection, so cache results per request and avoid loading full collections or calling external services.
I am available for freelance Magento and Adobe Commerce work, including promotions and cart rule logic. If you need help with this, get in touch.
Related articles
Magento 2 Custom Fee: Write a Totals Collector That Works
Add a custom fee to Magento 2 with a quote totals collector: sales.xml, collect and fetch, cart and checkout display, copying it to orders, invoices and PDFs.
Magento 2 Custom Payment Method with the Payment Gateway API
Build a custom payment method in Magento 2.4 using the Payment Gateway API: facade virtual type, config, commands and the checkout JS renderer, step by step.
Magento 2 Custom Shipping Method: Build a Carrier Module
How to build a custom shipping method in Magento 2.4: config.xml, system.xml, a carrier model with collectRates, free shipping rules and external rate APIs.
Magento 2 Page Builder: Create a Custom Content Type
Step by step custom Page Builder content type for Magento 2.4: content type XML, edit form, preview and master templates, storefront output and pitfalls.