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 you can create more advanced discounts that apply only in specific scenarios.

The logic for both the conditions and rules is specified using Symfony's expression language.

Available expressions

The following expressions are available for conditions and rules:

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() true/false, depending if the product is part of the given list. 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 amount Original price of the product Rules
Variable product Product object 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.

 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.

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.

The tolerance option allows you to make the discount usable for a longer period of time (for example, a day before or after the registration date) to allow more time for the customers to use it.

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)';
    }
}

The tolerance option is made available for usage in the expression by passing it in the constructor. The expression can evaluate to true or false depending on the custom expressions values.

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 using the PHP API.

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;
    }
}

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'