Extending Sales Rules in Magento 2: Cart Price Rules with Custom Conditions
AI generated
M2
di.xml
Magento 2 · Sales Rules · PHP 8.4 · Hyvä
Extending Sales Rules in Magento 2
custom conditions, actions and coupon logic for Cart Price Rules

Cart Price Rules are one of the most powerful tools in Magento 2, yet they quickly hit their limits once discount logic gets complex. Developers who master custom conditions, individual discount actions and programmatic coupon generation build Sales Rules that fit the business model exactly, instead of settling for compromises inside the standard rule engine. This post shows, with real code, how to cleanly extend the Magento_SalesRule module through di.xml, plugins and GraphQL.

18 min read AbstractCondition · RulesApplier · CouponGenerationSpec · GraphQL Magento 2.4.8-p4 · PHP 8.4

1. Sales Rules Fundamentals: the Cart Price Rules Architecture in the Magento_SalesRule Module

What the Magento backend calls "Cart Price Rules" is simply called Sales Rules in code, and it lives entirely inside the Magento_SalesRule module. The entity behind it is Magento\SalesRule\Model\Rule, stored in the salesrule table with mapping tables for website, customer group and coupons. Unlike Catalog Price Rules, which rewrite the catalog index as a cronjob, Sales Rules are evaluated live during cart totals calculation, every time a quote is recollected.

The central building blocks of the module are quickly named: RuleRepositoryInterface as the service contract for CRUD operations, Magento\SalesRule\Model\Validator as the entry point for rule application during collectTotals(), and RulesApplier for the actual discount calculation per item. This is complemented by CouponGenerationSpecInterface for bulk generation of coupon codes and a set of condition and action classes that map the rule tree in the admin form.

The real value for agencies emerges exactly where the default configuration stops being enough. Almost every project eventually reaches a point where a client requirement can no longer be expressed with the shipped conditions and actions. That is exactly what Magento's di.xml, plugins and service contracts are for: clean extension points without touching the core of the SalesRule module, and these extension points are the subject of this post.

2. The Rule Entity in the Data Model: Conditions and Actions as Serialized Object Trees

The salesrule table does not store a rule's condition logic as normalized rows, but as a serialized tree in the conditions_serialized and actions_serialized columns. Since Magento 2.2, Magento\Framework\Serialize\Serializer\Json handles this task, historically it was PHP's serialize(). When a rule is loaded, Rule::getConditions() builds an object hierarchy out of Combine nodes (logical AND/OR groups) and individual Condition leaves, each carrying an attribute, an operator and a value.

These object trees are not rebuilt on every access. Rule::getConditionsInstance() and getActionsInstance() cache the instantiated structure per rule object, so validation during totals collection does not deserialize the tree for every item in the cart. Anyone adding custom conditions or actions needs to understand this lifecycle: an object that is correctly saved in the admin form but does not return the same class name when loaded again leads to silent malfunctions in the Sales Rules instead of a clear exception.

Important in practice: the admin form for Sales Rules still runs on the classic UI components stack with Knockout.js, regardless of the fact that the frontend runs on Hyvä without jQuery and without Knockout. Anyone adding custom conditions is therefore working in the backend context with the existing form components, not with Alpine.js, which is only relevant for the storefront.

3. Adding Custom Conditions

The shipped conditions cover attribute comparisons, cart subtotal, weight or category membership, but they are not enough once external business logic needs to be involved, for example a loyalty tier coming from an ERP system or a subscription flag from a subscription module. For such cases, a custom condition class is created that extends Magento\Rule\Model\Condition\AbstractCondition and implements the methods loadAttributeOptions(), getInputType(), getValueElementType() and validate().

The validate() method receives the respective Magento\Framework\Model\AbstractModel as a parameter, in the context of Sales Rules either the quote address or the quote item, depending on where in the combine tree the condition is attached. External dependencies such as a segment resolver are injected via constructor property promotion, exactly like in any other service class. This keeps the condition testable and free of static calls.


<?php

declare(strict_types=1);

namespace Vendor\Module\Model\Rule\Condition;

use Magento\Framework\Model\AbstractModel;
use Magento\Rule\Model\Condition\AbstractCondition;
use Magento\Rule\Model\Condition\Context;
use Vendor\Module\Api\CustomerSegmentResolverInterface;

/**
 * Custom Sales Rule condition based on an externally resolved customer segment.
 */
class CustomerSegment extends AbstractCondition
{
    /**
     * @param Context $context Rule condition context required by the parent class.
     * @param CustomerSegmentResolverInterface $segmentResolver Resolves the segment for a customer id.
     * @param array $data Additional condition data.
     */
    public function __construct(
        Context $context,
        private readonly CustomerSegmentResolverInterface $segmentResolver,
        array $data = []
    ) {
        parent::__construct($context, $data);
        $this->setType(self::class);
    }

    /**
     * Registers the attribute option shown in the rule condition dropdown.
     *
     * @return $this
     */
    public function loadAttributeOptions(): self
    {
        $this->setAttributeOption(['segment' => __('Customer Segment (external)')]);
        return $this;
    }

    /**
     * @return string
     */
    public function getInputType(): string
    {
        return 'select';
    }

    /**
     * @return string
     */
    public function getValueElementType(): string
    {
        return 'select';
    }

    /**
     * @return array
     */
    public function getValueSelectOptions(): array
    {
        if (!$this->getData('value_select_options')) {
            $this->setData('value_select_options', [
                ['value' => 'vip', 'label' => __('VIP')],
                ['value' => 'wholesale', 'label' => __('Wholesale')],
                ['value' => 'standard', 'label' => __('Standard')],
            ]);
        }
        return $this->getData('value_select_options');
    }

    /**
     * Validates the condition against the current quote address.
     *
     * @param AbstractModel $model
     * @return bool
     */
    public function validate(AbstractModel $model): bool
    {
        $quote = $model->getQuote();
        $segment = $this->segmentResolver->resolveForCustomer((int) $quote->getCustomerId());
        return $this->validateAttribute($segment);
    }
}

The new condition is not registered through a fixed configuration list, but through a plugin on Magento\SalesRule\Model\Rule\Condition\Combine::getNewChildSelectOptions() that adds the custom class along with its label to the dropdown options of the rule editor. This matches the project standard of using plugins instead of preferences, and it prevents an update of the SalesRule module from overwriting the custom extension.


<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <type name="Magento\SalesRule\Model\Rule\Condition\Combine">
        <plugin name="vendor_module_custom_segment_condition"
                type="Vendor\Module\Plugin\Rule\Condition\CombinePlugin"
                sortOrder="10" />
    </type>
</config>

4. Implementing Custom Discount Actions

Magento\SalesRule\Model\RulesApplier::applyRule() is responsible for the actual discount calculation. The class iterates over cart items and, depending on the configured action type (by_percent, by_fixed, cart_fixed, buy_x_get_y), calculates the discount amount per item. For entirely new discount logic, for example tiered discounts based on an external loyalty score or different rounding rules per currency, there is no native extension point without touching the core. The pragmatic approach mandated by the project standard is a plugin on RulesApplier.

An after plugin on applyRule() receives the already calculated discount result and can adjust it before it is returned. External services such as a loyalty calculator are again injected via constructor property promotion. It matters that such a plugin only takes effect when the Sales Rule carries a custom, additional attribute, for example through an extension of salesrule_extension via db_schema.xml, so that standard rules remain untouched.


<?php

declare(strict_types=1);

namespace Vendor\Module\Plugin\SalesRule;

use Magento\Quote\Model\Quote\Item\AbstractItem;
use Magento\SalesRule\Model\Rule;
use Magento\SalesRule\Model\Rule\Action\Discount\Data as DiscountData;
use Magento\SalesRule\Model\RulesApplier;
use Vendor\Module\Api\LoyaltyDiscountCalculatorInterface;

/**
 * Adds an additional loyalty-tier discount on top of the standard Sales Rule calculation.
 */
class LoyaltyDiscountPlugin
{
    /**
     * @param LoyaltyDiscountCalculatorInterface $loyaltyCalculator Calculates the extra discount amount.
     */
    public function __construct(
        private readonly LoyaltyDiscountCalculatorInterface $loyaltyCalculator
    ) {
    }

    /**
     * @param RulesApplier $subject
     * @param DiscountData $result
     * @param AbstractItem $item
     * @param Rule $rule
     * @param bool $qty
     * @param float $rulePercent
     * @return DiscountData
     */
    public function afterApplyRule(
        RulesApplier $subject,
        DiscountData $result,
        AbstractItem $item,
        Rule $rule,
        bool $qty,
        float $rulePercent
    ): DiscountData {
        if (!$rule->getData('loyalty_tier_discount')) {
            return $result;
        }

        $extraDiscount = $this->loyaltyCalculator->calculate($item, $rule);
        if ($extraDiscount > 0.0) {
            $result->setAmount($result->getAmount() + $extraDiscount);
            $result->setBaseAmount($result->getBaseAmount() + $extraDiscount);
        }

        return $result;
    }
}

When combining multiple actions, the combined discount must never exceed the item price. RulesApplier already enforces this limit internally, but custom plugins must respect it when they increase the amount afterwards. Equally important: the plugin must check the store scope if the loyalty logic is only meant to apply to certain websites, otherwise the additional discount unintentionally applies across every store in the setup.

5. Controlling Coupon Generation and Validation Programmatically

For bulk generation of coupon codes, Magento provides CouponGenerationSpecInterface together with Magento\SalesRule\Model\CouponGenerator. In the admin grid, this powers the "Generate" mass action, which internally builds a specification made of prefix, suffix, length, character set and quantity. The same specification can be built programmatically, for example to create an individual, unique code for every newsletter subscriber or every B2B account, without taking the detour through the backend.

For additional validation rules, for example binding a code to a specific customer segment or a maximum redemption per calendar month, a plugin on Magento\SalesRule\Model\Coupon::loadByCode() or on the validation logic in Validator is a good fit. Such checks come on top of the standard fields uses_per_customer and uses_per_coupon and often query the salesrule_coupon_usage table to track redemptions already made by a customer.

Agencies that regularly need large volumes of codes usually build their own console command that injects CouponGenerationSpecInterfaceFactory and CouponGenerator and can be invoked via bin/magento. This saves the manual trip through the admin grid for every campaign and can be wired directly into deployment or import pipelines, without an editor having to trigger every batch by hand.

6. The Rule Collector and Performance in Complex Rule Sets

Magento\SalesRule\Model\Validator::process() is the entry point that runs on every totals collection of a quote. Internally, the validator loads, via the rule collection, all rules active for the website, customer group and, if applicable, coupon code, and processes them in ascending sort_order. For every rule, the conditions are first checked against address and items before RulesApplier applies the corresponding actions.

On stores with many Sales Rules active at once, the cost per item and per quote save multiplies quickly. Every additional rule means additional condition checks on every cart update. Anyone writing custom conditions should deliberately keep the validate() method lean and avoid repeated database queries per item, instead caching results within the request using an injected cache array.

An additional performance lever is consistently maintaining the validity window (to_date) instead of only deactivating expired campaigns. Inactive rules that still exist in the database are not applied, but they unnecessarily extend the load time of the rule collection if they are not filtered out cleanly. Regularly cleaning up old Sales Rules pays off noticeably as the rule count grows.

7. GraphQL and Rule Application in Checkout

In a headless or Hyvä checkout context, coupon application runs through the GraphQL mutation applyCouponToCart. The corresponding resolver from Magento\QuoteGraphQl delegates to Magento\Quote\Model\Cart\CouponManagement, which in turn triggers collectTotals() on the quote, running through the same Validator::process() path as a classic storefront request. For Hyvä checkouts, this is the central entry point that custom frontend components call into.


mutation ApplyCouponToCart($cartId: String!, $couponCode: String!) {
  applyCouponToCart(
    input: { cart_id: $cartId, coupon_code: $couponCode }
  ) {
    cart {
      applied_coupons {
        code
      }
      prices {
        discounts {
          amount {
            value
          }
          label
        }
        grand_total {
          value
          currency
        }
      }
    }
  }
}

At the final placeOrder step, Sales Rules are evaluated once more, because the quote runs through collectTotals() again immediately before conversion into an order. This prevents a cart that has been sitting open in the browser for a long time from being placed with a discount that has since expired or been deactivated. Custom conditions and plugins therefore need to be idempotent, since they are potentially executed several times per order flow.

For error handling, custom validations inside plugins should throw Magento\Framework\Exception\LocalizedException and never a generic exception. Only then does GraphQL format the error message correctly inside the errors array of the response, instead of returning an internal server error without readable text, which a frontend developer can then barely surface in a meaningful way.

8. Testing Cart Price Rules

For integration tests of Sales Rules, Magento\TestFramework\Helper\Bootstrap::getObjectManager() is the natural choice, combined with fixtures that create a rule via RuleRepositoryInterface::save() and attach it to a quote fixture. The test then checks whether collectTotals() writes the expected discount amount onto the quote, covering the full chain from condition validation to discount calculation.


<?php

declare(strict_types=1);

namespace Vendor\Module\Test\Integration\SalesRule;

use Magento\Quote\Api\CartRepositoryInterface;
use Magento\SalesRule\Api\RuleRepositoryInterface;
use Magento\SalesRule\Api\Data\RuleInterfaceFactory;
use Magento\TestFramework\Helper\Bootstrap;
use PHPUnit\Framework\TestCase;

/**
 * Integration test verifying that a custom loyalty discount rule
 * reduces the quote subtotal by the expected amount.
 */
class LoyaltyDiscountRuleTest extends TestCase
{
    /**
     * @magentoDataFixture Magento/Sales/_files/quote_with_customer.php
     * @magentoDbIsolation enabled
     */
    public function testLoyaltyDiscountReducesSubtotal(): void
    {
        $objectManager = Bootstrap::getObjectManager();

        $ruleFactory = $objectManager->get(RuleInterfaceFactory::class);
        $ruleRepository = $objectManager->get(RuleRepositoryInterface::class);

        $rule = $ruleFactory->create();
        $rule->setName('Loyalty Tier Test Rule');
        $rule->setCustomerGroupIds([0, 1]);
        $rule->setWebsiteIds([1]);
        $rule->setSimpleAction('by_percent');
        $rule->setDiscountAmount(10);
        $rule->setData('loyalty_tier_discount', 1);
        $ruleRepository->save($rule);

        $quoteRepository = $objectManager->get(CartRepositoryInterface::class);
        $quote = $quoteRepository->get(1);
        $quote->collectTotals();
        $quoteRepository->save($quote);

        $this->assertGreaterThan(0, (float) $quote->getSubtotal() - (float) $quote->getSubtotalWithDiscount());
    }
}

Fixtures for Sales Rules should cover at least customer group, website assignment and validity window, because these fields are the ones most often forgotten in practice, causing tests to fail for seemingly no reason. On top of that, isolated unit tests for the custom condition class are worthwhile, mocking $model and the quote objects. That checks the pure condition logic in milliseconds, without needing the full object manager bootstrap of an integration test.

9. Common Pitfalls with Rule Combinations and Priority

Once several Sales Rules are active at the same time, order decides the outcome. The sort_order field determines the order in which rules are checked, stop_rules_processing ends evaluation of further, lower-priority rules as soon as a rule matches, and discard_subsequent_rules marks individual items as "already discounted" so that following actions skip them. A classic production bug occurs when two rules carry the same sort_order value: the actual order then depends on the rule_id and behaves differently on staging and live once rules have been created in a different sequence.

Option Scope Effect Typical Use Case
sort_order Rule level Determines check order, lower number first Run an exclusive rule before generic promotions
stop_rules_processing Rule level Stops evaluation of further, lower-priority rules Prevents stacking with generic discounts
discard_subsequent_rules Item level Marks items as already discounted Prevents double discounting with free-gift rules
from_date / to_date Rule level Limits validity to a time window Time-limited campaigns without manual deactivation
coupon_type Rule level Controls whether a code is required Public promotion versus exclusive partner codes

Other common pitfalls: a free shipping rule interacts with a percentage discount rule differently than the rule editor suggests at first glance, because shipping costs are calculated separately. Coupon codes are matched case insensitively internally, but shown case sensitively in the admin grid, which can lead to duplicates in import scripts. And anyone evaluating a custom attribute on a rule via a plugin needs to remember that getConditionsInstance() and getActionsInstance() are cached, so a plain database update on the rule record only takes effect once the rule is reloaded.

10. Summary

Sales Rules in Magento 2 are not a rigid configuration screen, they are a module with clean, well-defined extension points. Custom conditions extend the condition tree via AbstractCondition and a plugin on Combine, without changing the core. Custom discount logic is built through plugins on RulesApplier, consistently implemented with constructor property promotion and service contracts. Coupon generation and validation can be controlled entirely programmatically, including bulk generation through a custom CLI command.

Anyone extending Cart Price Rules also needs to keep an eye on performance: every additional rule and every additional condition costs computation time on every totals collection. Priority and order through sort_order, stop_rules_processing and discard_subsequent_rules decide whether multiple rules interact correctly, and this is exactly where most production bugs originate. Integration tests with the Magento Test Framework make sure custom Sales Rules keep working reliably even after future changes.

Extending Sales Rules in Magento 2, the essentials at a glance

Custom Conditions

Extend AbstractCondition, register through a plugin on Combine::getNewChildSelectOptions() instead of a fixed configuration list.

Custom Discount Logic

A plugin on RulesApplier::applyRule() for individual discount calculation, cleanly testable and safe against core updates.

Coupons Programmatically

CouponGenerationSpecInterface for bulk generation, custom validation through a plugin on Coupon::loadByCode().

Priority and Performance

Consistently maintain sort_order, stop_rules_processing and discard_subsequent_rules, keep conditions lean.

11. FAQ: Extending Sales Rules in Magento 2

1What is the difference to Catalog Price Rules?
Sales Rules run live in the cart and support coupon codes. Catalog Price Rules run as a cronjob against the catalog index. This post covers only the extension of Sales Rules.
2What does a custom condition class extend?
Magento\Rule\Model\Condition\AbstractCondition, with the methods loadAttributeOptions(), getInputType(), getValueElementType() and validate() to override.
3How do I register a custom condition?
Through a plugin on Combine::getNewChildSelectOptions(), which adds the custom class to the dropdown list of the rule editor.
4How do I implement custom discount logic?
Through a plugin on RulesApplier::applyRule(), adjusting the calculated discount before it is returned, for example for tiered loyalty discounts.
5How do I generate coupon codes programmatically?
Through CouponGenerationSpecInterface and CouponGenerator, ideally in a custom bin/magento console command for bulk generation without the admin grid.
6What does stop_rules_processing mean?
Ends evaluation of further, lower-priority rules as soon as this rule matches. Prevents unwanted combination with generic promotions.
7Difference to discard_subsequent_rules?
stop_rules_processing acts at rule level, discard_subsequent_rules only at item level and marks individual items as already discounted.
8How does applyCouponToCart relate to rules?
The mutation delegates to CouponManagement, which triggers collectTotals() and thereby runs through the same validator path as a classic request.
9How do I test custom Sales Rules?
With integration tests through Bootstrap::getObjectManager() and fixtures, complemented by isolated unit tests for the pure condition logic.
10Why does a rule change take effect with a delay?
getConditionsInstance() and getActionsInstance() cache the object structure per rule object. An update only takes effect once the rule is reloaded.

Mironsoft

Magento 2 development, Sales Rules and checkout logic

Sales Rules that truly reflect your business model?

We extend your Cart Price Rules with custom conditions, individual discount actions and programmatic coupon generation, cleanly through di.xml and plugins instead of risky core changes.

Custom Conditions

Custom conditions for external segments, loyalty tiers and individual customer logic

RulesApplier Plugins

Individual discount logic without core changes, cleanly testable and safe for updates

GraphQL Checkout

Coupon application and rule validation for Hyvä and headless checkouts