Skip to main content
Version: Next

Catalog Price Rules

Added in Thelia 3.2.

A catalog price rule prices a slice of the catalog without touching a single product sheet. "20% off the Winter category in January" or "9.90 on brand X for these customers" are each one rule. The new price shows in the listings, on the product page, in the cart, on the order and through the front API.

The rule follows the catalog. A product filed in the Winter category on January 15th is priced by the rule from that moment. The rule stops at midnight on February 1st without any job having to run.

A rule is made of three things:

  • A scope: which products it prices.
  • An audience: everyone, or the customers named on the rule.
  • An effect: a percentage off, an amount off per currency, or a fixed price per currency.

On top of that a rule has a priority, a "stop" flag, optional dates, and a choice to show the catalog price struck through.

Creating a rule​

Back office: Tools > Catalog price rules, then New rule. Type a name: the rule is created turned off, and its other settings are set on the edit screen.

FieldMeaning
Rule name, descriptionFree text, translatable.
Turn this rule onA rule only prices while it is on.
PriorityRules covering the same product apply from the smallest priority to the largest, then by creation order. The default is 100.
Stop examining the other rulesOnce this rule has applied to a product, the following rules are skipped for it.
Start date, end dateBoth optional. Without an end date the rule runs until it is turned off. The end date must be after the start date.
EffectPercentage off, amount off per currency, or fixed price per currency.
Percentage taken offBetween 0 and 100. Used by the percentage effect.
Value per currencyUsed by the amount and fixed price effects. Typed tax included, at the shop location. A currency left blank is not priced by the rule. At least one currency is required.
Show the catalog price struck throughShows the original price next to the rule price.
Who this rule prices forEveryone, or named customers only.

Amounts and fixed prices are typed tax included, like the offsets of a flash sale. Customer groups are not available yet: the audience is everyone or a list of customers. A rule reserved for named customers needs at least one customer, and changing the audience needs the permission to view customers.

The scope​

The scope is a set of criteria:

  • Categories, with or without the categories below them (the A category criterion also covers the categories below it switch).
  • Brands.
  • Product templates.
  • Feature values.
  • Attribute values.
  • Named products. A named product is covered whatever the other criteria say about it.

The criteria combine with AND: a product has to match every type of criterion that names something. Within one type, naming several values means any of them. A type left empty restricts nothing, so a rule with no criterion at all covers the whole catalog.

Deleting a category or a brand a rule names never widens the rule: the deleted target matches nothing. The rule list flags such a criterion so the merchant can remove it.

Previewing before turning on​

Preview prices shows, for the rule as the form stands, how many products and sale elements it covers and the price of each one before and after, tax included, per currency. A greyed price is one the rule leaves unchanged. Use it before turning a rule on.

How rules combine​

Two rules covering the same product apply by ascending priority, then by ascending id. Each one works on the price the previous one left. A rule with the stop flag is the last one examined for the products it applies to.

A rule always starts from the catalog price (product_price.price) and replaces the promo price the visitor would otherwise see. That includes a manual promo price and a running flash sale: while a rule covers a product, the visitor sees the rule price.

Nothing is ever written into product_price or product_sale_elements.promo. The manual promo price is back the moment the rule ends. A rule that would take a price below zero floors it at zero and logs it.

The customer discount, if the customer has one, applies on top of the rule price, exactly as it applies to a promo price.

With reserved sales​

A reserved sale comes after the rules. A customer named on a reserved sale gets its discount only when it beats the price they would otherwise pay (the rule price included).

Where the price applies​

PlaceBehaviour
Product loopsThe product and product_sale_elements loops return the rule price in place of the promo price. The PROMO_PRICE, IS_PROMO and BEST_PRICE outputs follow it, and SHOW_ORIGINAL_PRICE of the product loop carries the struck-through choice.
Product pageThe sale elements of the product carry the rule price.
CartThe price is written into the cart line (cart_item.promo_price) by Thelia\Action\Cart.
OrderThe order freezes the price the cart line carried. An order keeps the price it was placed at after the rule ends.
Front API/api/front/products and /api/front/product_sale_elements return the rule price.

Two details on the loops. When a public rule is on, the min_price and max_price orders and filters of the product loop read the rule prices. What a rule reserved for named customers gives is not in these orders: a customer entitled to such a rule sees a listing ordered by the public prices. The promo="1" filter still reads product_sale_elements.promo and knows nothing of the rules.

Converting a flash sale​

On Tools > Sales, the action Convert to a catalog price rule builds turned-off rules from a sale: its dates, audience and customers, its percentage or amount per currency, and its products as named-product criteria. The sale is left as it is.

A sale selects (product, attribute value) pairs with OR, which one rule cannot express. The pairs are grouped by their set of attribute values and each group becomes one rule, with a warning.

Keeping prices up to date​

The rule prices are stored as dated segments in a table of their own, so an opening or a closing takes effect at the second. The core rewrites them itself when:

  • a rule is created, changed, turned on or off, or deleted;
  • a product, its categories, template, feature values or sale elements change, in the back office or through the admin API;
  • a category, brand, template, feature value or attribute value a rule names changes or is deleted;
  • a tax, a tax rule or a currency changes.

A change touching more sale elements than the catalog_price_rule_inline_recompute_limit shop setting (5000 by default) is not replayed during the request. The rule is flagged as pending ("Prices pending" in the list) and the recompute command finishes the job.

php bin/console catalog-price-rule:recompute # pending rules, then purge of segments already over
php bin/console catalog-price-rule:recompute --full # rebuild the scope and prices of every rule
php bin/console catalog-price-rule:recompute --rule=12 # one rule, by id
php bin/console catalog-price-rule:recompute --product=340 # one product against every rule, by id
php bin/console catalog-price-rule:recompute --purge-only # only drop the segments already over

Schedule it with the system cron, next to sale:check-activation. Every few minutes is plenty: the dates are carried by the segments, and the command only catches up what the events could not finish. The Recompute pending prices button of the rule list does the same as the default run.

The command exits with 1 when it fails or when --rule names a rule that does not exist.

Shared API cache​

A public rule gives everyone the same price, so the front API responses stay cacheable while it runs. A rule reserved for named customers makes the answer depend on who asks: the two endpoints above bypass the shared cache while such a rule is on. The cache is flushed on every CATALOG_PRICE_RULE_* event and by the recompute command.

For developers​

Reading rules through the API​

The admin API exposes rules read-only: GET /api/admin/catalog_price_rules and GET /api/admin/catalog_price_rules/{id}, filterable by id, audienceMode, effectType and active, and ordered by priority, startDate or endDate. A rule is written through its events, which is what rebuilds its scope and its stored prices.

Events​

Create, change, toggle and delete a rule by dispatching these events, as the back office does:

ConstantEvent class
TheliaEvents::CATALOG_PRICE_RULE_CREATEThelia\Core\Event\CatalogPriceRule\CatalogPriceRuleCreateEvent
TheliaEvents::CATALOG_PRICE_RULE_UPDATECatalogPriceRuleUpdateEvent (constructor takes the rule id, same payload as a creation)
TheliaEvents::CATALOG_PRICE_RULE_TOGGLE_ACTIVITYCatalogPriceRuleToggleActivityEvent (rule id, optional wanted state; without one the rule is flipped)
TheliaEvents::CATALOG_PRICE_RULE_DELETECatalogPriceRuleDeleteEvent (rule id)
TheliaEvents::CATALOG_PRICE_RULE_RECOMPUTECatalogPriceRuleRecomputeEvent (one rule when named, every pending rule otherwise)

The creation event carries the whole definition: locale, title, description, active, priority, stopProcessing, dates, effectType, percentageValue, effectValuesByCurrency (currency id to amount or price, tax included), audienceMode, customerIds, displayInitialPrice, includeSubcategories and criteria (criterion type to list of target ids). The core validates it and throws Thelia\Domain\Pricing\Rule\Exception\InvalidCatalogPriceRuleException on a refusal. After the action has run, getCatalogPriceRule() returns the rule.

Listen to the same events to react to a change, for instance to log it or to flush your own cache.

Adding a criterion type​

A module adds a criterion type, say "supplier", by shipping one service implementing Thelia\Domain\Pricing\Rule\Scope\ScopeCriterionResolverInterface. The interface is autoconfigured with the thelia.catalog_price_rule.criterion tag, so implementing it is enough.

<?php

namespace MyModule\PriceRule;

use Thelia\Domain\Pricing\Rule\Scope\ScopeCriterionResolverInterface;
use Thelia\Model\CatalogPriceRule;

final class SupplierCriterionResolver implements ScopeCriterionResolverInterface
{
public function type(): string
{
return 'supplier';
}

public function predicate(array $targetIds, CatalogPriceRule $rule): string
{
// Only the `pse` (product_sale_elements) and `p` (product) aliases are readable.
// The ids are integers inlined by the resolver: nothing else is bound.
return \sprintf('p.supplier_id IN (%s)', implode(',', array_map('intval', $targetIds)));
}
}

The rule editor then stores criteria of that type, and the core asks the module for their SQL predicate when it computes the scope. A criterion type that no installed resolver answers for makes the rule cover nothing, and the back office flags it.

Adding an audience mode​

Thelia\Domain\Pricing\Rule\Audience\AudienceResolverInterface (tag thelia.catalog_price_rule.audience) answers, for one audience_mode value, which turned-on rules a customer is entitled to. The core ships the named customers mode. Customer groups (audience_mode 2) are modelled and will plug in here.

Pricing by rules of your own​

Thelia\Domain\Pricing\CatalogPriceResolverInterface is the contract every reader of a price (loops, front API, product page service, cart) asks, once for the sale elements it is about to show. A module that prices by rules of its own decorates it: it answers for the sale elements it covers and hands the rest to the core resolver.

use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
use Thelia\Domain\Pricing\CatalogPriceResolverInterface;

#[AsDecorator(CatalogPriceResolverInterface::class)]
final class MyPriceResolver implements CatalogPriceResolverInterface
{
public function __construct(
#[AutowireDecorated] private readonly CatalogPriceResolverInterface $inner,
) {
}

// resolve(array $productSaleElementsIds, Currency $currency, ?Customer $customer, ?\DateTimeInterface $now = null): array
}

resolve() returns Thelia\Domain\Pricing\ResolvedCatalogPrice objects keyed by sale element id, untaxed. A sale element absent from the answer keeps its catalog price. The core alias to Rule\CatalogPriceRuleResolver is declared in Config/Resources/services/core/pricing.php, so a decorator of a module does not remove it.

If your prices depend on the visitor or on the cart, also decorate Thelia\Domain\Pricing\PricingActivityChecker and answer hasVisitorDependentPricing(). The core then makes the shared API cache step aside.

Where things live​

PiecePlace
Tables and migrationlocal/config/schema.xml, setup/update/sql/3.2.0.sql
Arithmetic (pure)core/lib/Thelia/Domain/Pricing/Rule/Engine/
Scope, audience, storagecore/lib/Thelia/Domain/Pricing/Rule/{Scope,Audience,Storage}/
Repricing after a changeThelia\Domain\Pricing\Rule\RuleRepricer
Resolution and combination with promos and reserved salesThelia\Domain\Pricing\EffectivePriceResolver
Events and actionscore/lib/Thelia/Core/Event/CatalogPriceRule/, Thelia\Action\CatalogPriceRule, Thelia\Action\CatalogPriceRuleCatalogSync
Flash sale conversionThelia\Domain\Pricing\Rule\Conversion\SaleToPriceRuleConverter
CommandThelia\Command\CatalogPriceRuleRecomputeCommand
Back-office screensdefault-twig theme, /admin/catalog-price-rule

The full technical reference, with the data model, the arithmetic and the stored segments, is docs/catalog-price-rules.md in the thelia/thelia repository.