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:
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).
Leave a Reply