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:
| Part | Where | What it does |
|---|---|---|
| Backend | PHP class + pimcore_ecommerce_framework configuration | Evaluates the condition / executes the action, and converts its settings from and to JSON. |
| Studio UI | Studio 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:
- the key in the configuration (
pimcore_ecommerce_framework.pricing_manager.conditions.<type>), - the
typeentry returned by the PHP class'toJSON(), - the
idof 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 theEnvironmentInterfacewith 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 thetype. The type is how Studio and the backend find the matching class.fromJSON()receives the JSON thattoJSON()would produce, plus theoperator,bracketLeftandbracketRightentries. 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 intoJSON()as[id, path]pairs and read them as{id, path}objects infromJSON(), likeCatalogProductandCatalogCategorydo.
Custom Action
An action implements Pimcore\Bundle\EcommerceFrameworkBundle\PricingManager\ActionInterface and one or both of:
ProductActionInterfacewithexecuteOnProduct(), to change the price of a product,CartActionInterfacewithexecuteOnCart(), 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 id | Registry |
|---|---|
EcommerceBundle/PricingRules/ConditionRegistry | DynamicTypeRuleConditionRegistry |
EcommerceBundle/PricingRules/ActionRegistry | DynamicTypeRuleActionRegistry |
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
defaultValueof a condition when the condition is added to a rule. The form therefore reports the defaults itself, as theuseEffectin 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 mergedconfigin the example):RuleConfigFormonly readsconfigwhen it is mounted, so the fields would stay empty although the default is saved. Actions are different: theirdefaultValueis 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 UI | Studio UI | |
|---|---|---|
PHP class and conditions / actions configuration | required | required, unchanged |
| UI part | function in pimcore.bundle.EcommerceFramework.pricing.conditions / .actions | dynamic type registered in the rule builder registries |
| Which types are offered in the editor | types from the backend configuration that have a matching JavaScript function | types registered in the UI registries |
| Form | Ext JS form panel | React form, rendered by renderForm |
| Labels | t('...') with admin translations | translation keys in the studio translation domain |
| Delivery | JavaScript file that is loaded into the Classic Admin UI | Studio 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.