Skip to content

Extend Discounts

By extending Discounts, you can increase flexibility and control over how promotions are applied to suit your unique business rules. Together with the existing events and the Discounts PHP API, extending discounts gives you the ability to cover additional use cases related to selling products.

Tip

If you prefer learning from videos, two presentations from Ibexa Summit 2025 cover the Discounts feature:

Create custom conditions and rules

With custom conditions and rules you can create more advanced discounts that apply only in specific scenarios.

For both of them, you need to specify their logic with Symfony's expression language.

Available expressions

You can use the following built-in expressions (variables and functions) in your own custom conditions and rules. You can also create your own.

Type Name Value Available for
Function get_current_region() Region object of the current siteaccess. Conditions, rules
Function is_in_category() true/false, depending if a product belongs to given product categories. Conditions, rules
Function is_user_in_customer_group() true/false, depending if an user belongs to given customer groups. Conditions, rules
Function calculate_purchase_amount() Purchase amount, calculated for all products in the cart before the discounts are applied. Conditions, rules
Function is_product_in_product_codes() Parameters:
- Product object
- array of product codes
Returns true if the product is part of the given list.
Conditions, rules
Function is_valid_discount_code() Parameter: discount code (string).
Returns true if the discount code is valid for current user.
Conditions, rules
Variable cart Cart object associated with current context. Conditions, rules
Variable currency Currency object of the current siteaccess. Conditions, rules
Variable customer_group Customer group object associated with given price context or the current user. Conditions, rules
Variable product Product object Conditions, rules
Variable amount Original price of the product Rules

Custom expressions

You can create your own variables and functions to make creating the conditions easier. The examples below show how to add an additional variable and a function to the available ones:

  • New variable: current_user_registration_date

It's a DateTime object with the registration date of the currently logged-in user.

To add it, create a class implementing the DiscountVariablesResolverInterface:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<?php declare(strict_types=1);

namespace App\Discounts\ExpressionProvider;

use Ibexa\Contracts\Core\Repository\PermissionResolver;
use Ibexa\Contracts\Core\Repository\UserService;
use Ibexa\Contracts\Discounts\DiscountVariablesResolverInterface;
use Ibexa\Contracts\ProductCatalog\Values\Price\PriceContextInterface;

final class CurrentUserRegistrationDateResolver implements DiscountVariablesResolverInterface
{
    private PermissionResolver $permissionResolver;

    private UserService $userService;

    public function __construct(PermissionResolver $permissionResolver, UserService $userService)
    {
        $this->permissionResolver = $permissionResolver;
        $this->userService = $userService;
    }

    /**
     * @return array{current_user_registration_date: \DateTimeInterface}
     */
    public function getVariables(PriceContextInterface $priceContext): array
    {
        return [
            'current_user_registration_date' => $this->userService->loadUser(
                $this->permissionResolver->getCurrentUserReference()->getUserId()
            )->getContentInfo()->publishedDate,
        ];
    }
}

And mark it as a service using the ibexa.discounts.expression_language.variable_resolver service tag:

1
2
3
    App\Discounts\ExpressionProvider\CurrentUserRegistrationDateResolver:
        tags:
            - ibexa.discounts.expression_language.variable_resolver
  • New function: is_anniversary()

It's a function returning a boolean value indicating if today is the anniversary of the date passed as an argument. The function accepts an optional argument, tolerance, allowing you to extend the range of dates that are acccepted as anniversaries. This implementation is simplified and does not cover the approach for accounts created on February 29 during leap years.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
<?php declare(strict_types=1);

namespace App\Discounts\ExpressionProvider;

use DateTimeImmutable;
use DateTimeInterface;

final class IsAnniversaryResolver
{
    private const YEAR_MONTH_DAY_FORMAT = 'Y-m-d';

    private const MONTH_DAY_FORMAT = 'm-d';

    private const REFERENCE_YEAR = 2000;

    public function __invoke(DateTimeInterface $date, int $tolerance = 0): bool
    {
        $d1 = $this->unifyYear(new DateTimeImmutable());
        $d2 = $this->unifyYear($date);

        $diff = $d1->diff($d2, true)->days;

        // Check if the difference between dates is within the tolerance
        return $diff <= $tolerance;
    }

    private function unifyYear(DateTimeInterface $date): DateTimeImmutable
    {
        // Create a new date using the reference year but with the same month and day
        $newDate = DateTimeImmutable::createFromFormat(
            self::YEAR_MONTH_DAY_FORMAT,
            self::REFERENCE_YEAR . '-' . $date->format(self::MONTH_DAY_FORMAT)
        );

        if ($newDate === false) {
            throw new \RuntimeException('Failed to unify year for date.');
        }

        return $newDate;
    }
}

Mark it as a service using the ibexa.discounts.expression_language.function service tag and specify the function name in the service definition.

1
2
3
4
    App\Discounts\ExpressionProvider\IsAnniversaryResolver:
        tags:
            - name: ibexa.discounts.expression_language.function
              function: is_anniversary

Two new expressions are now available for use in custom conditions and rules.

When deciding whether to register a new custom variable or function, consider the following:

  • variables are always evaluated by the expression engine and the result is available for all the rules and conditions specified in the discount
  • functions are invoked only when the rule or condition using them is evaluated. If there are multiple conditions using them, they will be invoked multiple times

For performance reasons, it's recommended to:

  • use variables only for lightweight calculations
  • use functions for resource-intensive calculations (for example, checking customer's order history)
  • implement caching (for example, in-memory) for function results to avoid redundant calculations when multiple discounts expressions might use the function
  • specify the most resource-intensive conditions as the last to evaluate. As all conditions must be met for the discount to apply, it's possible to skip evaluating them if the previous ones won't be met

In a production implementation, you should consider refactoring the current_user_registration_date variable into a get_current_user_registration_date function to avoid always loading the current user object and improve performance.

Implement custom condition

The following example creates a new discount condition. It allows you to offer a special discount for customers on the date when their account was created, making use of the expressions added above.

Create the condition by creating a class implementing the DiscountConditionInterface:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<?php declare(strict_types=1);

namespace App\Discounts\Condition;

use Ibexa\Contracts\Discounts\Value\DiscountConditionInterface;
use Ibexa\Discounts\Value\AbstractDiscountExpressionAware;

final class IsAccountAnniversary extends AbstractDiscountExpressionAware implements DiscountConditionInterface
{
    public const IDENTIFIER = 'is_account_anniversary';

    public function __construct(?int $tolerance = null)
    {
        parent::__construct([
            'tolerance' => $tolerance ?? 0,
        ]);
    }

    public function getTolerance(): int
    {
        return $this->getExpressionValue('tolerance');
    }

    public function getIdentifier(): string
    {
        return self::IDENTIFIER;
    }

    public function getExpression(): string
    {
        return 'is_anniversary(current_user_registration_date, tolerance)';
    }
}

This condition can be used in both catalog and cart discounts. To implement a cart-only discount, additionally implement the marker CartDiscountConditionInterface interface.

The tolerance option is made available for usage in the expression by passing it in the constructor. The getExpression() method contains the logic of the condition, expressed using the variables and functions available in the expression engine. The expression must evaluate to true or false, indicating whether the condition is met.

The example uses three expressions:

  • the custom is_anniversary() function, returning a value indicating whether today is user's registration anniversary
  • the custom current_user_registration_date variable, holding the value of current user's registration date
  • the custom tolerance variable, holding the acceptable tolerance (in days) for the calculation

For each custom condition class, you must create a dedicated condition factory, a class implementing the \Ibexa\Discounts\Repository\DiscountCondition\DiscountConditionFactoryInterface inteface.

This allows you to create conditions when working in the context of the Symfony service container.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<?php declare(strict_types=1);

namespace App\Discounts\Condition;

use Ibexa\Contracts\Discounts\Value\DiscountConditionInterface;
use Ibexa\Discounts\Repository\DiscountCondition\DiscountConditionFactoryInterface;

final class IsAccountAnniversaryConditionFactory implements DiscountConditionFactoryInterface
{
    public function createDiscountCondition(?array $expressionValues): DiscountConditionInterface
    {
        return new IsAccountAnniversary(
            $expressionValues['tolerance'] ?? null
        );
    }
}

Mark it as a service using the ibexa.discounts.condition.factory service tag and specify the condition's identifier.

1
2
3
4
    App\Discounts\Condition\IsAccountAnniversaryConditionFactory:
        tags:
            -   name: ibexa.discounts.condition.factory
                discriminator: !php/const App\Discounts\Condition\IsAccountAnniversary::IDENTIFIER

You can now use the condition, for example by using the PHP API or data migrations:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
-   type: discount
    mode: create
# ...
    conditions:
        -   
            identifier: is_in_currency
            expressionValues: 
                currency_code: EUR
        -
            identifier: is_product_in_array
            expressionValues:
                product_codes: 
                    - product_code_book_0
                    - product_code_book_1

        -
            identifier: is_account_anniversary
            expressionValues:
                tolerance: 5

To learn how to integrate it into the back office, see Extend Discounts wizard.

Implement custom rules

The following example implements a purchasing power parity discount, adjusting product's price in the cart based on buyer's region. You could use it, for example, in regions sharing the same currency and apply the rule only to them by using the IsInRegions condition.

To implement a custom rule, create a class implementing the DiscountRuleInterface.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
<?php declare(strict_types=1);

namespace App\Discounts\Rule;

use Ibexa\Contracts\Discounts\Value\DiscountRuleInterface;
use Ibexa\Discounts\Value\AbstractDiscountExpressionAware;

final class PurchasingPowerParityRule extends AbstractDiscountExpressionAware implements DiscountRuleInterface
{
    public const TYPE = 'purchasing_power_parity';

    private const DEFAULT_PARITY_MAP = [
        'default' => 100,
        'germany' => 81.6,
        'france' => 80,
        'spain' => 69,
    ];

    /** @param ?array<string, float> $powerParityMap */
    public function __construct(?array $powerParityMap = null)
    {
        parent::__construct(
            [
                'power_parity_map' => $powerParityMap ?? self::DEFAULT_PARITY_MAP,
            ]
        );
    }

    /** @return array<string, float> */
    public function getMap(): array
    {
        return $this->getExpressionValue('power_parity_map');
    }

    public function getExpression(): string
    {
        return 'amount * (power_parity_map[get_current_region().getIdentifier()] / power_parity_map["default"])';
    }

    public function getType(): string
    {
        return self::TYPE;
    }
}

The getExpression() method contains the logic of the rule, expressed using the variables and functions available in the expression engine. The expression must return the new price of the product.

It uses three expressions:

  • the built-in amount variable, holding the purchase amount
  • the built-in get_current_region() function, returning the current region
  • a custom power_parity_map variable, holding the purchasing power partity map. It's defined in the constuctor.

As with conditions, create a dedicated rule factory:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<?php declare(strict_types=1);

namespace App\Discounts\Rule;

use Ibexa\Contracts\Discounts\Value\DiscountRuleInterface;
use Ibexa\Discounts\Repository\DiscountRule\DiscountRuleFactoryInterface;

final class PurchasingPowerParityRuleFactory implements DiscountRuleFactoryInterface
{
    public function createDiscountRule(?array $expressionValues): DiscountRuleInterface
    {
        return new PurchasingPowerParityRule($expressionValues['power_parity_map'] ?? null);
    }
}

Then, mark it as a service using the ibexa.discounts.rule.factory service tag and specify the rule's type.

1
2
3
4
    App\Discounts\Rule\PurchasingPowerParityRuleFactory:
        tags:
            - name: ibexa.discounts.rule.factory
              discriminator: !php/const App\Discounts\Rule\PurchasingPowerParityRule::TYPE

You can now use the rule with the PHP API, but to use it within the back office and storefront you need to:

Custom discount value formatting

You can adjust how each discount type is displayed when using the ibexa_discounts_render_discount_badge Twig function by implementing a custom formatter.

You must implement a custom formatter for each custom rule.

To do it, create a class implementing the DiscountValueFormatterInterface and use the ibexa.discounts.value.formatter service tag:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
<?php

declare(strict_types=1);

namespace App\Discounts\Rule;

use Ibexa\Contracts\Discounts\DiscountValueFormatterInterface;
use Ibexa\Contracts\Discounts\Value\DiscountRuleInterface;
use Money\Money;

final class PurchaseParityValueFormatter implements DiscountValueFormatterInterface
{
    public function format(DiscountRuleInterface $discountRule, ?Money $money = null): string
    {
        return 'Regional discount';
    }
}
1
2
3
4
    App\Discounts\Rule\PurchaseParityValueFormatter:
        tags:
            - name: ibexa.discounts.value.formatter
              rule_type: !php/const App\Discounts\Rule\PurchasingPowerParityRule::TYPE

Change discount priority

You can change the the default discount priority by creating a class implementing the DiscountPrioritizationStrategyInterface and aliasing to it the default implementation.

The example below decorates the default implementation to prioritize recently updated discounts above all the others. It uses one of the existing discount search criterions.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?php declare(strict_types=1);

namespace App\Discounts;

use Ibexa\Contracts\Discounts\DiscountPrioritizationStrategyInterface;
use Ibexa\Contracts\Discounts\Value\Query\SortClause\UpdatedAt;

final class RecentDiscountPrioritizationStrategy implements DiscountPrioritizationStrategyInterface
{
    private DiscountPrioritizationStrategyInterface $inner;

    public function __construct(DiscountPrioritizationStrategyInterface $inner)
    {
        $this->inner = $inner;
    }

    public function getOrder(): array
    {
        return array_merge(
            [new UpdatedAt()],
            $this->inner->getOrder()
        );
    }
}
1
2
3
4
    App\Discounts\RecentDiscountPrioritizationStrategy:
        decorates: Ibexa\Contracts\Discounts\DiscountPrioritizationStrategyInterface
        arguments:
            $inner: '@.inner'