/coupon — Coupons de promotion Sylius
Tu aides à créer, appliquer ou générer en masse des coupons Sylius. Un PromotionCoupon est un code unique rattaché à une Promotion marquée couponBased=true : la promotion ne s'applique que si l'order porte un coupon valide ($order->setPromotionCoupon($coupon)).
Référence officielle : docs.sylius.com/the-book/carts-and-orders/coupons.
Détection préalable (obligatoire)
- Lire
composer.jsonà la racine. - Vérifier
sylius/sylius(ousylius/promotion-bundle/sylius/core-bundle) dans les dépendances.- Présent → OK.
- Absent → « Ce skill cible Sylius. Je ne trouve pas
sylius/syliusdans composer.json. Tu confirmes qu'on continue ? »
- Si la promotion support n'existe pas encore → enchaîner avec
/sylius:cart-promotionpour la créer en modecouponBased=trueavant de câbler les coupons. - Si l'envoi du code par e-mail est nécessaire → enchaîner avec
/sylius:email.
Anatomie d'un coupon
| Champ | Rôle | Obligatoire |
|---|---|---|
code |
Identifiant unique du coupon (valeur tapée par le client) | Oui |
expiresAt / expirationDate |
Date d'expiration (null = permanent) | Non |
usageLimit |
Nombre max total d'utilisations (tous clients confondus) | Non |
perCustomerUsageLimit |
Quota par customer (nécessite un client rattaché à l'order) |
Non |
used |
Compteur incrémenté par le PromotionProcessor à chaque commande qui consomme le coupon |
Géré par Sylius |
promotion |
Promotion parente (doit être couponBased=true) |
Oui |
Composants clés
| Composant | Service / Classe | Rôle |
|---|---|---|
| Factory Coupon | sylius.factory.promotion_coupon |
Crée un PromotionCouponInterface neuf |
| Repository Coupon | sylius.repository.promotion_coupon |
Persiste / recherche les coupons (souvent par code) |
| Factory Promotion | sylius.factory.promotion |
Crée la promotion parente (marquée couponBased=true) |
| Repository Promotion | sylius.repository.promotion |
Persiste la promotion (avec la collection de coupons cascade) |
| Coupon Generator | sylius.promotion_coupon_generator |
Génère N coupons uniques pour une promotion donnée |
| Generator Instruction | PromotionCouponGeneratorInstruction |
DTO : amount, codeLength, prefix, suffix, expiresAt, usageLimit |
| OrderProcessor | sylius.order_processing.order_processor |
Recalcule l'order et délègue au PromotionProcessor qui revalide le coupon |
| Eligibility Checker | sylius.promotion_coupon_eligibility_checker |
Vérifie validité (code, expiration, usage, promotion active) |
Règles fondamentales
- Toujours passer par les factories (
sylius.factory.promotion_coupon,sylius.factory.promotion). Unnew PromotionCoupon()direct produit une entité détachée, non traçable par le processor. - La promotion parente doit être
couponBased=true. Sans ce flag,addCoupon()lève uneInvalidArgumentExceptioncôté Sylius et la promo reste inerte même si un code est saisi. - Un coupon = une promotion. La relation est many-to-one côté coupon (plusieurs coupons peuvent référencer une même promo, mais un coupon n'appartient qu'à une promo).
- Le code du coupon est l'identifiant public. Il doit être unique globalement (contrainte DB sur
sylius_promotion_coupon.code). Les collisions font échouer le flush Doctrine. expiresAtest vérifié à l'application, pas à la création. Un coupon expiré reste en base mais est rejeté par lePromotionCouponEligibilityChecker— utile pour l'historique.usageLimitvsperCustomerUsageLimit: global vs par client. Le second nécessite que$order->getCustomer()soit non null ; sinon Sylius ne peut pas compter et la limite par client est ignorée silencieusement.- Application via l'order, pas le coupon.
$order->setPromotionCoupon($coupon)+OrderProcessor::process()est le seul chemin standard. Ne jamais appelerPromotionApplicatorpour un coupon — il court-circuite la vérification d'éligibilité du coupon. - Le compteur
usedne redescend pas sur un order annulé. Prévoir un listener surOrderTransitions::TRANSITION_CANCELsi la limite est critique. - Le Generator produit des codes aléatoires alphanumériques majuscules.
codeLengthpar défaut = 6. Avecprefix="NY_"etcodeLength=8, on obtientNY_A3F9K2LM.
Déroulement
Cas 1 — Créer un coupon unique sur une promotion existante
use Sylius\Component\Core\Model\PromotionInterface;
use Sylius\Component\Core\Model\PromotionCouponInterface;
use Sylius\Component\Resource\Factory\FactoryInterface;
use Sylius\Component\Resource\Repository\RepositoryInterface;
final class CreateFreeShippingCoupon
{
public function __construct(
private FactoryInterface $couponFactory,
private RepositoryInterface $promotionRepository,
) {}
public function __invoke(): PromotionCouponInterface
{
/** @var PromotionInterface $promotion */
$promotion = $this->promotionRepository->findOneBy(['code' => 'free_shipping']);
if (!$promotion->isCouponBased()) {
throw new \DomainException('La promotion doit être couponBased=true.');
}
/** @var PromotionCouponInterface $coupon */
$coupon = $this->couponFactory->createNew();
$coupon->setCode('FREESHIPPING2026');
$coupon->setExpiresAt(new \DateTime('2026-12-31 23:59:59'));
$coupon->setUsageLimit(500);
$promotion->addCoupon($coupon);
// Flush via le manager Doctrine (cascade persist configurée côté Sylius).
$this->promotionRepository->add($promotion);
return $coupon;
}
}
Le add() sur le repository de la promotion déclenche la cascade sur la collection coupons — pas besoin de flusher le coupon séparément.
Cas 2 — Créer promotion coupon-based + coupon en un seul flux
use Sylius\Component\Promotion\Factory\PromotionActionFactoryInterface;
final class CreateBlackFridayCoupon
{
public function __construct(
private FactoryInterface $promotionFactory,
private FactoryInterface $couponFactory,
private PromotionActionFactoryInterface $actionFactory,
private RepositoryInterface $promotionRepository,
private ChannelRepositoryInterface $channelRepository,
) {}
public function __invoke(string $channelCode): void
{
/** @var PromotionInterface $promotion */
$promotion = $this->promotionFactory->createNew();
$promotion->setCode('black_friday_2026');
$promotion->setName('Black Friday 2026');
$promotion->setCouponBased(true);
$promotion->addChannel($this->channelRepository->findOneByCode($channelCode));
$promotion->addAction($this->actionFactory->createPercentageDiscount(0.25));
/** @var PromotionCouponInterface $coupon */
$coupon = $this->couponFactory->createNew();
$coupon->setCode('BF25');
$coupon->setUsageLimit(1000);
$coupon->setPerCustomerUsageLimit(1);
$promotion->addCoupon($coupon);
$this->promotionRepository->add($promotion);
}
}
perCustomerUsageLimit=1 garantit qu'un même client ne peut utiliser BF25 qu'une seule fois — à condition que $order->getCustomer() soit non null au moment de l'application.
Cas 3 — Appliquer un coupon sur un order
use Sylius\Component\Core\OrderProcessing\OrderProcessorInterface;
final class ApplyCouponToOrder
{
public function __construct(
private RepositoryInterface $couponRepository,
private OrderProcessorInterface $orderProcessor,
) {}
public function __invoke(OrderInterface $order, string $couponCode): void
{
/** @var PromotionCouponInterface|null $coupon */
$coupon = $this->couponRepository->findOneBy(['code' => $couponCode]);
if (null === $coupon) {
throw new \DomainException(sprintf('Coupon %s introuvable.', $couponCode));
}
$order->setPromotionCoupon($coupon);
$this->orderProcessor->process($order);
}
}
L'OrderProcessor relance tout le pipeline : il appelle le PromotionProcessor qui revalide l'éligibilité du coupon (expiration, usage, promotion active, channel) avant d'appliquer l'action. Si le coupon est invalide, il est silencieusement ignoré — l'order reste sans discount.
Cas 4 — Générer 50 coupons en masse
use Sylius\Component\Promotion\Generator\PromotionCouponGeneratorInterface;
use Sylius\Component\Promotion\Model\PromotionCouponGeneratorInstruction;
final class BulkGenerateNewYearCoupons
{
public function __construct(
private RepositoryInterface $promotionRepository,
private PromotionCouponGeneratorInterface $couponGenerator,
) {}
public function __invoke(): void
{
/** @var PromotionInterface $promotion */
$promotion = $this->promotionRepository->findOneBy(['code' => 'new_year_sale']);
$instruction = new PromotionCouponGeneratorInstruction();
$instruction->setAmount(50);
$instruction->setCodeLength(8);
$instruction->setPrefix('NY26_');
$instruction->setSuffix('_VIP');
$instruction->setExpiresAt(new \DateTime('2026-01-31'));
$instruction->setUsageLimit(1);
$this->couponGenerator->generate($promotion, $instruction);
}
}
Le générateur garantit l'unicité dans la promotion cible, pas globalement — en théorie deux promotions distinctes peuvent produire le même suffixe aléatoire, mais la contrainte d'unicité DB sur code fera échouer le second flush. Anticiper en ajoutant un prefix distinct par promotion.
Cas 5 — Lister les coupons d'une promotion
$promotion = $this->promotionRepository->findOneBy(['code' => 'black_friday_2026']);
foreach ($promotion->getCoupons() as $coupon) {
printf("%s — utilisé %d/%s fois\n",
$coupon->getCode(),
$coupon->getUsed(),
$coupon->getUsageLimit() ?? '∞'
);
}
getCoupons() renvoie une Collection Doctrine. Pour des volumes > quelques centaines, préférer une requête DQL paginée sur sylius.repository.promotion_coupon avec filtre promotion = :promo.
Pièges fréquents
- Coupon sans promotion
couponBased=true: le coupon se crée mais n'est jamais pris en compte —PromotionCouponEligibilityCheckerignore le coupon si la promo parente n'est pas marquée. VérifierSELECT coupon_based FROM sylius_promotion WHERE id = .... - Code dupliqué : deux coupons avec le même
code(même sur deux promotions différentes) → violation de contrainte unique sursylius_promotion_coupon.code. Toujours préfixer par promotion ou passer par le Generator. perCustomerUsageLimitsilencieusement ignoré : sans$order->setCustomer(), Sylius ne peut pas indexer l'usage par client. Vérifier que le checkout rattache bien le customer avant d'appliquer le coupon.- Coupon expiré mais encore appliqué : le
PromotionCouponEligibilityCheckerrejette, mais si tu appellesPromotionApplicator::apply()directement, la vérification coupon est court-circuitée. Toujours passer parOrderProcessor::process(). - Compteur
usedqui explose en test : les fixtures rejouent la même commande → le coupon atteintusageLimit. En dev, laisserusageLimit=nullou resetsylius_promotion_coupon.usedentre les runs. - Prefix/suffix qui dépasse la longueur en BDD : la colonne
codeest unVARCHAR(255)par défaut, mais les fixtures et les UI coupent souvent à 32. Rester sous 32 caractères totaux (prefix + codeLength + suffix). - Generator appelé sur une promotion non coupon-based :
PromotionCouponGenerator::generate()lève une exception. Vérifier$promotion->isCouponBased()avant. - Order annulé → coupon toujours marqué utilisé : comme pour les promotions,
usedne se décrémente pas à l'annulation. Listener surOrderTransitions::TRANSITION_CANCELindispensable pour les coupons à quota strict (campagnes influenceur, cadeaux nominatifs). - Plusieurs coupons sur un même order :
Orderne porte qu'un seulpromotionCoupon. Pour cumuler plusieurs codes, il faut soit des promotions non coupon-based, soit un module tiers (ex.setono/sylius-coupons-plugin). - Coupons supprimés mais référencés par des orders : la FK
order.promotion_coupon_idpeut pointer vers un coupon supprimé si la cascade n'est pas configurée. PréférerexpiresAtdans le passé à une suppression hard.
Clôture
Afficher :
- Coupon(s) créé(s) (
code,expiresAt,usageLimit,perCustomerUsageLimit, promotion rattachée). - Services injectés dans le code proposé.
- Fichiers touchés.
- Ce qui reste : fixtures (
PromotionCouponFixture), tests Behat (features/promotion/using_coupon_*), listeners à câbler (décrément deusedà l'annulation), envoi d'e-mail du code (/sylius:email), endpoint d'application du coupon côté checkout (ApplyCouponAction).
Argument optionnel
/sylius:coupon create — cadre la création d'un coupon unique sur une promotion existante (Cas 1).
/sylius:coupon promotion — cadre la création d'une promotion coupon-based + coupon dans le même flux (Cas 2).
/sylius:coupon apply — cadre l'application d'un coupon sur un order via OrderProcessor (Cas 3).
/sylius:coupon generate — cadre la génération bulk via PromotionCouponGenerator (Cas 4).
/sylius:coupon list — cadre la lecture des coupons d'une promotion (Cas 5).
/sylius:coupon sans argument — demande le cas d'usage (création unitaire, promotion + coupon, application, génération bulk, listing).