Cart and Catalog Rules

Catalog price rules are indexed and change prices; cart price rules run during totals collection and can carry a coupon.

Syntax$couponManagement->set((int) $cartId, 'WELCOME10');

Magento has two promotion engines with similar admin screens and completely different mechanics. Picking the wrong one is the usual cause of a rule that will not behave.

Catalog price rules

A catalog price rule changes a product's price before anyone adds it to a cart, so the discount shows on category and product pages. Its conditions can only read product attributes, and it has no coupon. The result is written into an index: the catalogrule_rule and catalogrule_product indexers, with catalog_product_price declaring a dependency on catalogrule_rule. Save the rule and the storefront does not move until those run, which is why a rule that "does not work" usually needs bin/magento indexer:reindex catalogrule_rule catalogrule_product catalog_product_price followed by bin/magento cache:flush. A cron job named catalogrule_apply_all reapplies them daily at 01:00.

Cart price rules

A cart price rule is evaluated while the quote's totals are collected, every single time. It is not indexed at all. In exchange it sees the whole cart: subtotal, quantities, shipping country and the set of items. It can also carry a coupon code, and it can grant free shipping.

Each rule needs websites and customer groups, a coupon_type, a simple_action such as by_percent, by_fixed, cart_fixed or buy_x_get_y, and a discount_amount. Rules run in sort_order, and stop_rules_processing on a matching rule stops the ones after it. Apply a code to a cart with Magento\Quote\Api\CouponManagementInterface::set($cartId, $couponCode).

Customer segments, which target promotions by shopper behaviour, exist only in Adobe Commerce. Open Source gives you websites, customer groups and coupons.

Example

Example · php
<?php
// app/code/Vendor/Promo/Setup/Patch/Data/AddWelcomeCoupon.php
declare(strict_types=1);

namespace Vendor\Promo\Setup\Patch\Data;

use Magento\Framework\Setup\Patch\DataPatchInterface;
use Magento\SalesRule\Model\ResourceModel\Rule as RuleResource;
use Magento\SalesRule\Model\Rule;
use Magento\SalesRule\Model\RuleFactory;

class AddWelcomeCoupon implements DataPatchInterface
{
    public function __construct(
        private readonly RuleFactory $ruleFactory,
        private readonly RuleResource $ruleResource
    ) {
    }

    public function apply(): self
    {
        $rule = $this->ruleFactory->create();
        $rule->setName('Welcome 10 percent');
        $rule->setDescription('Ten percent off, one use per customer.');
        $rule->setIsActive(1);
        $rule->setWebsiteIds([1]);
        $rule->setCustomerGroupIds([0, 1, 2, 3]);
        $rule->setCouponType(Rule::COUPON_TYPE_SPECIFIC);
        $rule->setCouponCode('WELCOME10');
        $rule->setUsesPerCustomer(1);

        // Rule::BY_PERCENT_ACTION is the constant for the literal 'by_percent'.
        $rule->setSimpleAction(Rule::BY_PERCENT_ACTION);
        $rule->setDiscountAmount(10);
        $rule->setStopRulesProcessing(false);
        $rule->setSortOrder(0);

        $this->ruleResource->save($rule);

        return $this;
    }

    public static function getDependencies(): array
    {
        return [];
    }

    public function getAliases(): array
    {
        return [];
    }
}

// bin/magento setup:upgrade runs the patch once. The rule then appears under
// Marketing > Cart Price Rules, and WELCOME10 takes 10 percent off any cart.
// No reindex is needed: cart rules are evaluated when totals are collected.

When to use it

  • Ship a launch coupon with a module instead of asking the client to type it into the admin.
  • Give 15 percent off one brand on category pages, where only a catalog rule shows the new price.
  • Explain why a saved catalog price rule has not changed any price yet.
  • Apply and remove a coupon from a headless front end.

More examples

Making a catalog price rule take effect

This is the sequence to reach for whenever a catalog price rule looks ignored. The dependency matters: catalog_product_price declares catalogrule_rule as a dependency, so reindexing prices alone can still show the old figure. Cart price rules appear in none of these commands because they are never indexed, which is also why a cart rule with heavy conditions costs time on every totals collection.

Example · bash
# A saved catalog price rule only reaches the storefront through the indexers.
bin/magento indexer:reindex catalogrule_rule catalogrule_product catalog_product_price

# Prices are also cached, so flush after the reindex.
bin/magento cache:flush

# Check the mode of each index. 'Update by Schedule' means mview picks changes up
# from the changelog instead of recalculating on save.
bin/magento indexer:show-mode catalogrule_rule catalogrule_product

# The nightly reapplication, which handles rules whose from/to dates have arrived.
bin/magento cron:run --group=default   # runs catalogrule_apply_all when due

Applying a coupon to a cart

set() writes the code onto the quote and recollects totals, so the discount line exists immediately afterwards. It throws rather than returning false when the code is unusable, and the two exception types do not split the way you might expect: NoSuchEntityException covers a bad cart id, a cart with no items, and a code the rule engine refused, because after saving set() compares the quote's coupon_code with the one you passed and throws "The coupon code isn't valid. Verify the code and try again." when they differ. CouldNotSaveException means the save or the totals collection itself failed. remove($cartId) clears the code again and throws CouldNotDeleteException on failure. Use this in a custom endpoint or an auto-apply flow rather than setting coupon_code on the quote by hand.

Example · php
<?php
// app/code/Vendor/Promo/Model/ApplyCoupon.php
declare(strict_types=1);

namespace Vendor\Promo\Model;

use Magento\Framework\Exception\CouldNotSaveException;
use Magento\Framework\Exception\NoSuchEntityException;
use Magento\Quote\Api\CouponManagementInterface;

class ApplyCoupon
{
    public function __construct(
        private readonly CouponManagementInterface $couponManagement
    ) {
    }

    public function execute(int $cartId, string $code): bool
    {
        try {
            return (bool) $this->couponManagement->set($cartId, $code);
        } catch (CouldNotSaveException | NoSuchEntityException $e) {
            // The code does not exist, is expired, or does not match this cart.
            return false;
        }
    }
}

Listing the active cart rules

Search criteria bind their values, so a coupon code or rule name coming from a request is safe here in a way that concatenating it into a collection filter would not be. Note the asymmetry between the two engines: SalesRule's repository is getById() and deleteById(), while Magento\CatalogRule\Api\CatalogRuleRepositoryInterface uses get() and delete(). Reach for this when you need to show shoppers which promotions are running without a coupon.

Example · php
<?php
// app/code/Vendor/Promo/Model/ActiveRules.php
declare(strict_types=1);

namespace Vendor\Promo\Model;

use Magento\Framework\Api\SearchCriteriaBuilder;
use Magento\SalesRule\Api\Data\RuleInterface;
use Magento\SalesRule\Api\RuleRepositoryInterface;

class ActiveRules
{
    public function __construct(
        private readonly RuleRepositoryInterface $ruleRepository,
        private readonly SearchCriteriaBuilder $searchCriteriaBuilder
    ) {
    }

    /**
     * @return RuleInterface[]
     */
    public function withoutCoupon(): array
    {
        $criteria = $this->searchCriteriaBuilder
            ->addFilter('is_active', 1)
            ->addFilter('coupon_type', RuleInterface::COUPON_TYPE_NO_COUPON)
            ->create();

        return $this->ruleRepository->getList($criteria)->getItems();
    }
}

Discussion

  • Be the first to comment on this lesson.