Create customized promotions with Sylius

Let’s say you have an e-commerce site that uses Sylius.
You need to set up a promotion system. This promotion will be applied to the entire site and must not be modifiable. It will have a completely customized business logic and will depend on specific business rules1.

You could define promotions with the default promotion rules and link them to actions. However, this doesn’t really work with Sylius’ basic promotion rules:

  • Cart quantity
  • Customer group
  • Nth order
  • Shipping country
  • Has at least one from taxons
  • Total price of items from taxon
  • Contains product Item total

The method I propose in this article is divided into 5 parts:

Table of Contents

    Expand the Entity

    Declaring the services we are going to extend

    By default, Sylius defines its own data models. For Promotion, there’s Sylius\Component\Core\Model\Promotion and Sylius\Bundle\CoreBundle\Doctrine\ORM\PromotionRepository. We’re going to extend them and declare them in the file below so that Sylius knows that we want to use these PHP classes and no longer those of Sylius.

    sylius_promotion:
      resources:
        promotion:
          classes:
            model: App\Entity\Promotion\Promotion
            repository: App\Repository\Promotion\PromotionRepository

    Create the entity

    We extend the Sylius Promotion class with our own Promotion class. This allows us to add our own properties and class constants.

    <?php
    
    declare(strict_types=1);
    
    namespace App\Entity\Promotion;
    
    use Doctrine\ORM\Mapping as ORM;
    use Sylius\Component\Core\Model\Promotion as BasePromotion;
    
    #[ORM\Entity]
    #[ORM\Table(name: 'sylius_promotion')]
    class Promotion extends BasePromotion
    {
        public const DEFAULT_PROMOTION_CODE = 'default_promotion';
    }

    Create the promotion

    Now I need to create the promotion and insert it into the database so that it can be retrieved when the user uses the site. It’s possible to do this directly from the administration, but as I want to be able to iterate easily and make sure the promotion still exists, I’m going to create it via a Symfony command.
    To do this, I’ll use Sylius’ FactoryInterface, to which I’ll add autowiring via the #[Autowire] attribute.

    <?php
    
    declare(strict_types=1);
    
    namespace App\Command\Contact;
    
    use Doctrine\ORM\EntityManagerInterface;
    use Sylius\Component\Channel\Context\ChannelContextInterface;
    use Sylius\Component\Resource\Factory\FactoryInterface;
    use Symfony\Component\Console\Attribute\AsCommand;
    use Symfony\Component\Console\Command\Command;
    use Symfony\Component\Console\Input\InputInterface;
    use Symfony\Component\Console\Output\OutputInterface;
    use Symfony\Component\DependencyInjection\Attribute\Autowire;
    
    #[AsCommand(name: 'app:create-default-promotion', description: 'Create the default promotion')]
    class CreateDefaultPromotionCommand extends Command
    {
        public function __construct(
            private readonly EntityManagerInterface $entityManager,
            #[Autowire(service: 'sylius.factory.promotion')]
            private readonly FactoryInterface $factory,
            private readonly ChannelContextInterface $channelContext,
        ) {
            parent::__construct();
        }
    
        protected function execute(InputInterface $input, OutputInterface $output): int
        {
            /** @var \App\Entity\Promotion\Promotion $promotion */
            $promotion = $this->factory->createNew();
            $promotion->setCode(Promotion::DEFAULT_PROMOTION_CODE);
            $promotion->setName('Default Promotion');
            $promotion->setDescription('Default Promotion used globally on the store');
            $promotion->setPriority(0);
            $promotion->setExclusive(true);
            $promotion->setUsed(0);
            $promotion->setCouponBased(false);
            $promotion->setAppliesToDiscounted(true);
            $promotion->setCreatedAt(new \DateTimeImmutable());
    
            $promotion->addChannel($this->channelContext->getChannel());
    
            $this->entityManager->persist($promotion);
            $this->entityManager->flush();
            return Command::SUCCESS;
        }
    }

    Hide the promotion in the back office

    We don’t want the promotion to be visible in the administration. To exclude the promotion from the administration, simply create a method in the PromotionRepository we’ve created. This is another advantage of extending Sylius classes: we can create our own methods specific to our needs. Here, we exclude from the query all promotions with a code equal to Promotion::DEFAULT_PROMOTION_CODE, the constant we defined earlier.

    Create the repository

    <?php
    
    declare(strict_types=1);
    
    namespace App\Repository\Promotion;
    
    use App\Entity\Promotion\Promotion;
    use Doctrine\ORM\QueryBuilder;
    use Sylius\Bundle\CoreBundle\Doctrine\ORM\PromotionRepository as BasePromotionRepository;
    use Sylius\Component\Core\Repository\PromotionRepositoryInterface;
    
    /**
     * @method Promotion|null find($id, $lockMode = null, $lockVersion = null)
     * @method Promotion|null findOneBy(array $criteria, array $orderBy = null)
     * @method Promotion[]    findAll()
     * @method Promotion[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
     */
    class PromotionRepository extends BasePromotionRepository implements PromotionRepositoryInterface
    {
        /**
         * This function allows to hide the default custom DEFAULT_PROMOTION_CODE in admin BO
         */
        public function createAdminQueryBuilder(): QueryBuilder
        {
            return $this->_em->createQueryBuilder()
                ->select('p')
                ->from($this->_entityName, 'p')
                ->andWhere('p.code != :promotion')
                ->setParameter('promotion', Promotion::DEFAULT_PROMOTION_CODE)
            ;
        }
    }

    In the sylius_grid file, we’ve changed the queryBuilder used by the administration so as not to display the default promotion. As it’s not supposed to be deleted or modified, it’s best to hide it. Even if it is actually still accessible via the editing URL, knowing its ID.

    sylius_grid:
      grids:
        sylius_admin_promotion:
          driver:
            name: doctrine/orm
            options:
              class: "%sylius.model.promotion.class%"
              repository:
                method: 'createAdminQueryBuilder'

    Promotion eligibility

    Creation of Eligibility Checker

    Now we need to create the eligibility checker. This is a class that will say whether or not the object passed in parameter2 is eligible for promotion. In our case, we’re going to authorize the promotion only if the user has an account on the site.

    <?php
    
    declare(strict_types=1);
    
    namespace App\Promotion\Checker\Eligibility;
    
    use App\Entity\Order\Order;
    use App\Entity\Promotion\Promotion;
    use Sylius\Component\Promotion\Checker\Eligibility\PromotionEligibilityCheckerInterface;
    use Sylius\Component\Promotion\Exception\UnsupportedTypeException;
    use Sylius\Component\Promotion\Model\PromotionInterface;
    use Sylius\Component\Promotion\Model\PromotionSubjectInterface;
    
    class DefaultPromotionEligibilityChecker implements PromotionEligibilityCheckerInterface
    {
        public function isEligible(PromotionSubjectInterface $promotionSubject, PromotionInterface $promotion): bool
        {
            if (!$promotionSubject instanceof Order) {
                throw new UnsupportedTypeException($promotionSubject, Order::class);
            }
    
            if ($promotion->getCode() !== Promotion::DEFAULT_PROMOTION_CODE) {
                return false;
            }
    
            /** @var \App\Entity\Customer\Customer|null $customer */
            $customer = $promotionSubject->getCustomer();
            // is elligible if the customer is a customer
            return $customer !== null;
        }
    }

    Now that the checker has been created, we need to add it to the services.yaml file so that it is taken into account by PromotionProcessor.

    services:  
      app.default_promotion_eligibility_checker:
        class: App\Promotion\Checker\Eligibility\DefaultPromotionEligibilityChecker
        public: false
        tags:
          - { name: sylius.promotion_eligibility_checker }

    PromotionProcessor

    The PromotionProcessor is a class that will scan and execute all eligibility checkers until it finds the first one returning true for the current object. If none is found, then the promotion logic is stopped, as nothing is applicable.

    <?php
    
    /*
     * This file is part of the Sylius package.
     *
     * (c) Sylius Sp. z o.o.
     *
     * For the full copyright and license information, please view the LICENSE
     * file that was distributed with this source code.
     */
    
    declare(strict_types=1);
    
    namespace Sylius\Component\Promotion\Processor;
    
    // ...
    
    final class PromotionProcessor implements PromotionProcessorInterface
    {
        public function __construct(
            private PreQualifiedPromotionsProviderInterface $preQualifiedPromotionsProvider,
            private PromotionEligibilityCheckerInterface $promotionEligibilityChecker,
            private PromotionApplicatorInterface $promotionApplicator,
        ) {
        }
    
        public function process(PromotionSubjectInterface $subject): void
        {
    		// ...
    
            $preQualifiedPromotions = $this->preQualifiedPromotionsProvider->getPromotions($subject);
    
            foreach ($preQualifiedPromotions as $promotion) {
                if ($promotion->isExclusive() && $this->promotionEligibilityChecker->isEligible($subject, $promotion)) {
                    $this->promotionApplicator->apply($subject, $promotion);
    
                    return;
                }
            }
    
            // ...
        }
    }

    What we’re interested in here is the PromotionEligibilityCheckerInterface. By default, Sylius will choose the CompositePromotionEligibilityChecker class to implement this interface. This class will then iterate over all classes implementing PromotionEligibilityCheckerInterface, including our eligibility checker. It will then be called via the isEligible($promotionSubject, $promotion) method.

    <?php
    
    declare(strict_types=1);
    
    namespace Sylius\Component\Promotion\Checker\Eligibility;
    
    // ...
    
    final class CompositePromotionEligibilityChecker implements PromotionEligibilityCheckerInterface
    {
        /** @var PromotionEligibilityCheckerInterface[] */
        private array $promotionEligibilityCheckers;
    
        // ...
    
        public function isEligible(PromotionSubjectInterface $promotionSubject, PromotionInterface $promotion): bool
        {
            foreach ($this->promotionEligibilityCheckers as $promotionEligibilityChecker) {
                if (!$promotionEligibilityChecker->isEligible($promotionSubject, $promotion)) {
                    return false;
                }
            }
    
            return true;
        }
    }

    Promotion Application

    The PromotionApplicator

    The PromotionApplicator is used by the PromotionProcessor. The PromotionProcessor receives a PromotionApplicatorInterface in its constructor during dependency injection. By default, this interface is instantiated by the PromotionApplicator class below:

    <?php
    // ...
    
    final class PromotionApplicator implements PromotionApplicatorInterface
    {
        public function __construct(private ServiceRegistryInterface $registry)
        {
        }
    
        public function apply(PromotionSubjectInterface $subject, PromotionInterface $promotion): void
        {
            $applyPromotion = false;
            foreach ($promotion->getActions() as $action) {
                $result = $this->getActionCommandByType($action->getType())->execute($subject, $action->getConfiguration(), $promotion);
                $applyPromotion = $applyPromotion || $result;
            }
    
            if ($applyPromotion) {
                $subject->addPromotion($promotion);
            }
        }
    
        public function revert(PromotionSubjectInterface $subject, PromotionInterface $promotion): void
        {
            // ...
        }
    
        private function getActionCommandByType(string $type): PromotionActionCommandInterface
        {
            return $this->registry->get($type);
        }
    }

    The class shows that the apply() method needs to retrieve the promotion’s actions. Actions are defined in the database. They can be, for instance:

    • Order discount
    • Fixed product discount
    • Order percentage discount
    • Product percentage discount
    • Percentage shipping discount

    However, we haven’t defined any. Our business logic is more complex and requires us to retrieve information from the database to know which discount amount to apply to which product.

    It’s also possible to assign an action via the interface or via our promotion creation command, but this doesn’t make much sense, so we might as well stick to promotions using rules and actions.

    Decorate the applicator

    In our case, as we don’t define an action on our promotion, we need a way to apply our logic anyway. If it’s not applicable in the current context, then by default we fall back on the logic of Sylius’ PromotionApplicator. To do this, we’re going to decorate Sylius’ PromotionApplicator with our own class.
    If we do bin/console debug:container PromotionApplicatorInterface of PromotionApplicator, we can see that Sylius assigns it the alias sylius.promotion_applicator. We’re going to use this alias to reference our decorated class.

    To do this, we’ll add the AsDecorator attribute, available since Symfony 6.1. We pass the alias as a parameter, and this allows us to retrieve the Sylius PromotionApplicator directly from our constructor. We add our logic to the apply() and revert() functions if we’re on an order and on the right promotion. If not, we call our decorated class to return to the basic Sylius behavior.

    <?php
    
    declare(strict_types=1);
    
    namespace App\Promotion\Action;
    
    use App\Entity\Order\Order;
    use App\Entity\Promotion\Promotion;
    use Sylius\Component\Promotion\Action\PromotionApplicatorInterface;
    use Sylius\Component\Promotion\Model\PromotionInterface;
    use Sylius\Component\Promotion\Model\PromotionSubjectInterface;
    use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
    
    #[AsDecorator('sylius.promotion_applicator')]
    final readonly class DefaultPromotionApplicator implements PromotionApplicatorInterface
    {
        public function __construct(
            private PromotionApplicatorInterface $promotionApplicator,
            #[Autowire(service: 'sylius.factory.adjustment')]
            private readonly FactoryInterface $adjustmentFactory,
        ) {
        }
    
        public function apply(PromotionSubjectInterface $subject, PromotionInterface $promotion): void
        {
            if ($subject instanceof Order && $promotion->getCode() === Promotion::DEFAULT_PROMOTION_CODE) {
            	/**
            	 * TODO :
            	 * Il reste a implémenter la logique métier, par exemple en injectant le repository d'un model
            	 * et en faisant des requêtes permettant de savoir si des remises sont disponibles pour le panier 
            	 * courant. Si oui, appliquer des modifications au panier suivant ces règles
            	 * 
            	 */
    
            	$amount = // ...
    
    
    	        $adjustment = $this->adjustmentFactory->createNew();
    	        $adjustment->setType(AdjustmentInterface::ORDER_PROMOTION_ADJUSTMENT);
    	        $adjustment->setAmount($amount);
    	        $adjustment->setNeutral(false);
    	        $adjustment->setLabel('Test Promotion Adjustment');
    	        $order->addAdjustment($adjustment);
    
                return;
            }
    
            $this->promotionApplicator->apply($subject, $promotion);
        }
    
        public function revert(PromotionSubjectInterface $subject, PromotionInterface $promotion): void
        {
            if ($subject instanceof Order && $promotion->getCode() === Promotion::DEFAULT_PROMOTION_CODE) {
            	/**
            	 * TODO :
            	 * Il reste a implémenter la logique métier, par exemple en injectant le repository d'un model
            	 * et en faisant des requêtes permettant de savoir si des remises sont disponibles pour le panier 
            	 * courant. Si oui, appliquer des modifications au panier suivant ces règles
            	 * 
            	 */
    
                return;
            }
    
            $this->promotionApplicator->revert($subject, $promotion);
        }
    }

    All that remains is to create an Adjustment and assign it to our order, so that the promotion is added to the basket.

    Conclusion

    We’ve seen how to set up a default, hidden promotion to manage business rules when modifying an order.
    Of course, the logic for retrieving discounts still needs to be implemented, but this depends on the case and the need. I’ve concentrated on a single use case with a specific configuration (exclusive promotion, not based on coupons, etc.), but this gives me an idea of the approach I take when I’m faced with a situation on Sylius where I want to deviate from what is normally proposed (in this case, promotion rules linked to promotion actions).

    1. I’m being vague, because business logic is not the aim of this article. ↩︎
    2. Always the order in this context. ↩︎

    Comments

    Leave a Reply

    Your email address will not be published. Required fields are marked *