Skip to main content
Version: Next

Extending Pricing Rules with Custom Conditions and Actions

Pricing Rules are built from conditions (when does a rule apply?) and actions (what does it do?). The framework ships with a set of both, and projects can add their own.

A custom condition or action always consists of two parts that are connected by one name, the type:

PartWhereWhat it does
BackendPHP class + pimcore_ecommerce_framework configurationEvaluates the condition / executes the action, and converts its settings from and to JSON.
Studio UIStudio plugin (TypeScript)Offers the type in the rule editor and renders the form for its settings.

Both parts are required. If only the PHP class is registered, the type is not offered in the rule editor. If only the UI type is registered, it can be added to a rule but saving fails with an InvalidConfigException, because the backend does not know the type.

Coming from the Classic Admin UI? The backend part is unchanged: PHP class and configuration work as before. Only the UI part is different. See Differences to the Classic Admin UI.

How the parts work together​

The type is the key in the conditions / actions configuration, e.g. CartAmount. It has to be identical in three places:

  1. the key in the configuration (pimcore_ecommerce_framework.pricing_manager.conditions.<type>),
  2. the type entry returned by the PHP class' toJSON(),
  3. the id of the Studio UI dynamic type.

When a rule is loaded, the backend calls toJSON() of each condition and action and returns the result to Studio. When a rule is saved, Studio sends the settings of each condition and action as JSON. The backend creates the object for the type from the configuration and passes the JSON to fromJSON().

The JSON of a condition or action is a flat object that contains type and the settings of the type. For example, the built-in CartAmount condition and CartDiscount action look like this:

{ "type": "CartAmount", "limit": 30000 }
{ "type": "CartDiscount", "amount": 0, "percent": 3, "onlyDiscountCart": false }

Conditions can be combined with and, or and and not and grouped with brackets. This is handled by the framework (the Bracket condition). Custom conditions do not have to deal with operators or brackets: Studio sends operator, bracketLeft and bracketRight along with the settings, the framework evaluates them, and fromJSON() can ignore them.

Backend​

Custom Condition​

A condition implements Pimcore\Bundle\EcommerceFrameworkBundle\PricingManager\ConditionInterface:

<?php

namespace App\PricingManager\Condition;

use Pimcore\Bundle\EcommerceFrameworkBundle\PricingManager\ConditionInterface;
use Pimcore\Bundle\EcommerceFrameworkBundle\PricingManager\EnvironmentInterface;

class CartItemCount implements ConditionInterface
{
protected int $minimum = 1;

public function check(EnvironmentInterface $environment): bool
{
// The condition is checked for products (product is set) and for carts (cart is set).
// This one only makes sense for carts.
if (!$environment->getCart() || $environment->getProduct() !== null) {
return false;
}

return $environment->getCart()->getItemCount() >= $this->getMinimum();
}

public function toJSON(): string
{
return json_encode([
'type' => 'CartItemCount',
'minimum' => $this->getMinimum(),
]);
}

public function fromJSON(string $string): ConditionInterface
{
$json = json_decode($string);
$this->setMinimum((int) ($json->minimum ?? 1));

return $this;
}

public function getMinimum(): int
{
return $this->minimum;
}

public function setMinimum(int $minimum): void
{
$this->minimum = $minimum;
}
}
  • check() receives the EnvironmentInterface with the context of the evaluation: the cart, the cart item, the product, the visitor info and the rule. Which of them are set depends on where the pricing manager is asked: for the price of a single product, the product is set; for the cart, the cart is set.
  • toJSON() must return the type. The type is how Studio and the backend find the matching class.
  • fromJSON() receives the JSON that toJSON() would produce, plus the operator, bracketLeft and bracketRight entries. Validate the incoming values here and throw an exception for invalid ones.
  • The class is instantiated with new, without constructor arguments and without dependency injection. Fetch services from the container yourself where needed (e.g. \Pimcore::getContainer()).

Settings that are arrays of two-element arrays, e.g. [[12, "/path"]] as written by the built-in relation based conditions, are converted by Studio into [{"id": 12, "path": "/path"}] when a rule is saved. If you store relations in your own condition, return them in toJSON() as [id, path] pairs and read them as {id, path} objects in fromJSON(), like CatalogProduct and CatalogCategory do.

Custom Action​

An action implements Pimcore\Bundle\EcommerceFrameworkBundle\PricingManager\ActionInterface and one or both of:

  • ProductActionInterface with executeOnProduct(), to change the price of a product,
  • CartActionInterface with executeOnCart(), to change the cart, e.g. by adding a price modificator.
<?php

namespace App\PricingManager\Action;

use Pimcore\Bundle\EcommerceFrameworkBundle\CartManager\CartPriceModificator\Discount;
use Pimcore\Bundle\EcommerceFrameworkBundle\PricingManager\Action\CartActionInterface;
use Pimcore\Bundle\EcommerceFrameworkBundle\PricingManager\ActionInterface;
use Pimcore\Bundle\EcommerceFrameworkBundle\PricingManager\EnvironmentInterface;
use Pimcore\Bundle\EcommerceFrameworkBundle\Type\Decimal;

class CartPercentDiscount implements ActionInterface, CartActionInterface
{
protected float $percent = 0;

public function executeOnCart(EnvironmentInterface $environment): ActionInterface
{
$priceCalculator = $environment->getCart()->getPriceCalculator();

$amount = $priceCalculator->getSubTotal()->getAmount()->toPercentage($this->getPercent());

// round to 2 digits to avoid fractional cents, then go back to the default scale of Decimal
$amount = Decimal::fromDecimal($amount->withScale(2))->toAdditiveInverse();

// apply a rule only once: update the modificator of this rule if it exists already
foreach ($priceCalculator->getModificators() as $modificator) {
if ($modificator instanceof Discount && $modificator->getRuleId() == $environment->getRule()->getId()) {
$modificator->setAmount($amount);
$priceCalculator->calculate(true);

return $this;
}
}

$modificator = new Discount($environment->getRule());
$modificator->setAmount($amount);

$priceCalculator->addModificator($modificator);
$priceCalculator->calculate(true);

return $this;
}

public function toJSON(): string
{
return json_encode([
'type' => 'CartPercentDiscount',
'percent' => $this->getPercent(),
]);
}

public function fromJSON(string $string): ActionInterface
{
$json = json_decode($string);

$percent = (float) ($json->percent ?? 0);
if ($percent < 0 || $percent > 100) {
throw new \InvalidArgumentException('The percentage must be between 0 and 100.');
}

$this->setPercent($percent);

return $this;
}

public function getPercent(): float
{
return $this->percent;
}

public function setPercent(float $percent): void
{
$this->percent = $percent;
}
}

The built-in actions in Pimcore\Bundle\EcommerceFrameworkBundle\PricingManager\Action are a good reference, e.g. ProductDiscount for the product level and Gift for adding cart items.

Register the Types​

Add the classes to the pricing manager configuration. The key is the type:

pimcore_ecommerce_framework:
pricing_manager:
conditions:
CartItemCount: App\PricingManager\Condition\CartItemCount
actions:
CartPercentDiscount: App\PricingManager\Action\CartPercentDiscount

The mapping is merged with the built-in types (Bracket, CartAmount, DateRange, ..., CartDiscount, Gift, ...), see the configuration reference. The GET /pricing/config Studio endpoint lists all registered types, see Studio Integration.

Studio UI​

The rule editor offers exactly the types that are registered in two Studio UI registries. Each type is a dynamic type, a class that provides the id, the label, default settings and the form of the type. The registries are services in the Studio UI service container:

Service idRegistry
EcommerceBundle/PricingRules/ConditionRegistryDynamicTypeRuleConditionRegistry
EcommerceBundle/PricingRules/ActionRegistryDynamicTypeRuleActionRegistry

Both are exported by @pimcore/studio-ui-bundle/modules/rule-builder, together with the base classes DynamicTypeRuleConditionAbstract and DynamicTypeRuleActionAbstract.

The dynamic types are part of a normal Studio plugin. How to set up the plugin (the assets folder, the build and the registration of the build) is described in the Studio UI documentation, see Getting Started with Your First Plugin and Registering the Frontend Build. The Studio Example Bundle is a complete working plugin.

Custom Condition​

The id has to be the type from the configuration. label is a translation key. The form is rendered by renderForm, which gets the current settings in props.value and reports changes with props.onChange:

import React, { useEffect } from 'react'
import { injectable, useTranslation } from '@pimcore/studio-ui-bundle/app'
import { Form, InputNumber } from '@pimcore/studio-ui-bundle/components'
import {
DynamicTypeRuleConditionAbstract,
RuleConfigForm,
type RuleBaseCondition,
type RuleConditionFormProps
} from '@pimcore/studio-ui-bundle/modules/rule-builder'

export interface CartItemCountCondition extends RuleBaseCondition {
minimum: number
}

const DEFAULT_VALUE = { minimum: 1 }

const CartItemCountForm = ({ value, onChange, disabled }: RuleConditionFormProps<CartItemCountCondition>): React.JSX.Element => {
const { t } = useTranslation()

// A new condition only has `id`, `type` and `operator`, and the form only reports changes made by the user.
// Report the defaults once, otherwise a condition that is saved untouched has no `minimum`.
useEffect(() => {
if (value.minimum === undefined) {
onChange({ ...DEFAULT_VALUE, ...value })
}
}, [])

return (
<RuleConfigForm
config={ { ...DEFAULT_VALUE, ...value } }
debounceOptions={ { delay: 300 } }
disabled={ disabled }
onChange={ (config) => { onChange({ ...value, ...config }) } }
>
<Form.Item
label={ t('my-bundle.pricing-rules.conditions.cart-item-count.minimum') }
name="minimum"
>
<InputNumber
min={ 1 }
precision={ 0 }
/>
</Form.Item>
</RuleConfigForm>
)
}

@injectable()
export class DynamicTypePricingConditionCartItemCount extends DynamicTypeRuleConditionAbstract<CartItemCountCondition> {
id = 'CartItemCount'
label = 'my-bundle.pricing-rules.conditions.cart-item-count.label'
defaultValue = DEFAULT_VALUE

renderForm = (props: RuleConditionFormProps<CartItemCountCondition>): React.ReactNode => (
<CartItemCountForm { ...props } />
)
}

The settings of the form (minimum) are sent to the backend as they are, next to type, and arrive in fromJSON().

Studio does not apply the defaultValue of a condition when the condition is added to a rule. The form therefore reports the defaults itself, as the useEffect in the example does. Without it, a condition that is saved without touching the form is sent without its settings. The form also needs the defaults as its initial values (the merged config in the example): RuleConfigForm only reads config when it is mounted, so the fields would stay empty although the default is saved. Actions are different: their defaultValue is applied when the action is added.

Optionally, a type can implement isAvailable() and notAvailableHint: if isAvailable() returns false, the type is disabled in the "Add" menu and a warning with the hint is shown where it is already used. This is useful for types that depend on another bundle.

Custom Action​

Actions work the same way with DynamicTypeRuleActionAbstract. The base class takes props.value as the settings object of the action. An action without settings returns null from renderForm, like the built-in FreeShipping:

import React from 'react'
import { injectable, useTranslation } from '@pimcore/studio-ui-bundle/app'
import { Form, InputNumber } from '@pimcore/studio-ui-bundle/components'
import {
DynamicTypeRuleActionAbstract,
RuleConfigForm,
type ActionFormProps
} from '@pimcore/studio-ui-bundle/modules/rule-builder'

export interface CartPercentDiscountConfig extends Record<string, unknown> {
percent: number
}

const CartPercentDiscountForm = ({ value, onChange, disabled }: ActionFormProps<CartPercentDiscountConfig>): React.JSX.Element => {
const { t } = useTranslation()

return (
<RuleConfigForm
config={ value }
debounceOptions={ { delay: 300 } }
disabled={ disabled }
onChange={ onChange }
>
<Form.Item
label={ t('my-bundle.pricing-rules.actions.cart-percent-discount.percent') }
name="percent"
>
<InputNumber
max={ 100 }
min={ 0 }
/>
</Form.Item>
</RuleConfigForm>
)
}

@injectable()
export class DynamicTypePricingActionCartPercentDiscount extends DynamicTypeRuleActionAbstract<CartPercentDiscountConfig> {
id = 'CartPercentDiscount'
label = 'my-bundle.pricing-rules.actions.cart-percent-discount.label'
defaultValue = { percent: 0 }

renderForm = (props: ActionFormProps<CartPercentDiscountConfig>): React.ReactNode => (
<CartPercentDiscountForm { ...props } />
)
}

Register the Dynamic Types​

Bind the classes in the service container and add the instances to the registries. The registries are bound by the Ecommerce Framework Bundle plugin in its onInit. Register the types in a module: modules run after all plugins have initialized their services, so the registries are guaranteed to exist, no matter in which order the plugins are loaded:

import { container, type AbstractModule, type IAbstractPlugin } from '@pimcore/studio-ui-bundle'
import {
type DynamicTypeRuleActionRegistry,
type DynamicTypeRuleConditionRegistry
} from '@pimcore/studio-ui-bundle/modules/rule-builder'
import { DynamicTypePricingConditionCartItemCount } from './dynamic-type-pricing-condition-cart-item-count'
import { DynamicTypePricingActionCartPercentDiscount } from './dynamic-type-pricing-action-cart-percent-discount'

const CONDITION_REGISTRY = 'EcommerceBundle/PricingRules/ConditionRegistry'
const ACTION_REGISTRY = 'EcommerceBundle/PricingRules/ActionRegistry'

const CartItemCountId = 'MyBundle/PricingRules/Condition/CartItemCount'
const CartPercentDiscountId = 'MyBundle/PricingRules/Action/CartPercentDiscount'

const PricingRuleExtensionModule: AbstractModule = {
onInit: (): void => {
const conditionRegistry = container.get<DynamicTypeRuleConditionRegistry>(CONDITION_REGISTRY)
const actionRegistry = container.get<DynamicTypeRuleActionRegistry>(ACTION_REGISTRY)

conditionRegistry.registerDynamicType(container.get(CartItemCountId))
actionRegistry.registerDynamicType(container.get(CartPercentDiscountId))
}
}

export const PricingRuleExtensionPlugin: IAbstractPlugin = {
name: 'my-bundle-pricing-rule-extension',

onInit: ({ container }): void => {
container.bind(CartItemCountId).to(DynamicTypePricingConditionCartItemCount).inSingletonScope()
container.bind(CartPercentDiscountId).to(DynamicTypePricingActionCartPercentDiscount).inSingletonScope()
},

onStartup: ({ moduleSystem }): void => {
moduleSystem.registerModule(PricingRuleExtensionModule)
}
}

Export the plugin in the plugins.ts of your Studio plugin, as described in the Studio UI documentation.

Translations​

The labels in the examples are translation keys. Add them to the studio translation domain of your bundle, as the Ecommerce Framework Bundle does in translations/studio.<locale>.yaml:

my-bundle.pricing-rules.conditions.cart-item-count.label: Minimum number of cart items
my-bundle.pricing-rules.conditions.cart-item-count.minimum: Minimum
my-bundle.pricing-rules.actions.cart-percent-discount.label: Percentage cart discount
my-bundle.pricing-rules.actions.cart-percent-discount.percent: Percent

Differences to the Classic Admin UI​

In the Classic Admin UI (Ecommerce Framework Bundle versions that ship the Classic Admin UI, platform version 2025.4), the UI part of a custom type was an Ext JS function that was added to a global JavaScript namespace. The function name had to be condition<Type> / action<Type>, and the type was only offered in the "Add" menu if it was also registered in the backend configuration:

pimcore.bundle.EcommerceFramework.pricing.conditions.conditionCartItemCount = function (panel, data, getName) {
// returns the display name when getName is set, otherwise an Ext.form.FormPanel
};

In Studio:

Classic Admin UIStudio UI
PHP class and conditions / actions configurationrequiredrequired, unchanged
UI partfunction in pimcore.bundle.EcommerceFramework.pricing.conditions / .actionsdynamic type registered in the rule builder registries
Which types are offered in the editortypes from the backend configuration that have a matching JavaScript functiontypes registered in the UI registries
FormExt JS form panelReact form, rendered by renderForm
Labelst('...') with admin translationstranslation keys in the studio translation domain
DeliveryJavaScript file that is loaded into the Classic Admin UIStudio plugin, see Getting Started with Your First Plugin

An existing custom type therefore needs no change in the PHP class and the configuration, but its UI part has to be rewritten as a Studio dynamic type.