Developing Custom Sylius Plugins
A Sylius plugin is a Symfony Bundle with additional structure following Sylius conventions. The extension mechanism is built on the Resource System: a plugin registers its resources, and Sylius automatically creates CRUD endpoints, APIs, and events for them. For customizing existing resources, the Decorator/Override pattern is used via sylius_*.yaml configuration.
Creating plugin structure
composer require --dev sylius-labs/plugin-skeleton
# or manually:
mkdir -p src/SyliusLoyaltyPlugin/{DependencyInjection,Entity,Form,Menu,Repository,Resources/config}
Minimal structure:
src/SyliusLoyaltyPlugin/
├── SyliusLoyaltyPlugin.php # Main Bundle class
├── DependencyInjection/
│ ├── Configuration.php
│ └── SyliusLoyaltyExtension.php
├── Entity/
│ └── LoyaltyAccount.php
├── Repository/
│ └── LoyaltyAccountRepository.php
├── Form/
│ └── Type/
│ └── LoyaltyAccountType.php
├── EventListener/
│ └── OrderPlacedListener.php
└── Resources/
├── config/
│ ├── services.xml
│ └── doctrine/
│ └── LoyaltyAccount.orm.xml
├── views/
│ └── Admin/
│ └── LoyaltyAccount/
└── translations/
└── messages.en.yaml
Main Bundle class
// src/SyliusLoyaltyPlugin/SyliusLoyaltyPlugin.php
namespace Acme\SyliusLoyaltyPlugin;
use Sylius\Bundle\CoreBundle\Application\SyliusPluginTrait;
use Symfony\Component\HttpKernel\Bundle\Bundle;
final class SyliusLoyaltyPlugin extends Bundle
{
use SyliusPluginTrait;
}
The SyliusPluginTrait allows the plugin to register resources through configuration and appear in the Sylius Plugin Registry.
Doctrine Entity
// src/SyliusLoyaltyPlugin/Entity/LoyaltyAccount.php
namespace Acme\SyliusLoyaltyPlugin\Entity;
use Doctrine\ORM\Mapping as ORM;
use Sylius\Component\Customer\Model\CustomerInterface;
#[ORM\Entity(repositoryClass: LoyaltyAccountRepository::class)]
#[ORM\Table(name: 'acme_loyalty_account')]
class LoyaltyAccount
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\OneToOne(targetEntity: CustomerInterface::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private CustomerInterface $customer;
#[ORM\Column(type: 'integer', options: ['default' => 0])]
private int $points = 0;
#[ORM\Column(type: 'json')]
private array $transactions = [];
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $createdAt;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
}
public function addPoints(int $points, string $reason, ?string $orderId = null): void
{
$this->points += $points;
$this->transactions[] = [
'type' => 'earn',
'points' => $points,
'reason' => $reason,
'order_id' => $orderId,
'date' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM),
];
}
public function spendPoints(int $points, string $reason): void
{
if ($this->points < $points) {
throw new \DomainException('Insufficient points');
}
$this->points -= $points;
$this->transactions[] = [
'type' => 'spend',
'points' => $points,
'reason' => $reason,
'date' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM),
];
}
public function getId(): ?int { return $this->id; }
public function getPoints(): int { return $this->points; }
public function getTransactions(): array { return $this->transactions; }
}
EventListener: awarding points on order
// src/SyliusLoyaltyPlugin/EventListener/OrderPlacedListener.php
namespace Acme\SyliusLoyaltyPlugin\EventListener;
use Acme\SyliusLoyaltyPlugin\Repository\LoyaltyAccountRepository;
use Doctrine\ORM\EntityManagerInterface;
use Sylius\Bundle\ResourceBundle\Event\ResourceControllerEvent;
use Sylius\Component\Core\Model\OrderInterface;
final class OrderPlacedListener
{
public function __construct(
private LoyaltyAccountRepository $accountRepository,
private EntityManagerInterface $em,
) {}
public function onOrderComplete(ResourceControllerEvent $event): void
{
/** @var OrderInterface $order */
$order = $event->getSubject();
$customer = $order->getCustomer();
if (!$customer) {
return; // guest order
}
$pointsToAward = (int) floor($order->getTotal() / 10000); // 1 point = 100 rubles
$account = $this->accountRepository->findOneByCustomer($customer);
if (!$account) {
$account = new LoyaltyAccount();
$account->setCustomer($customer);
}
$account->addPoints(
$pointsToAward,
sprintf('Order #%s', $order->getNumber()),
$order->getId()
);
$this->em->persist($account);
$this->em->flush();
}
}
<!-- src/SyliusLoyaltyPlugin/Resources/config/services.xml -->
<service id="acme.loyalty.event_listener.order_placed"
class="Acme\SyliusLoyaltyPlugin\EventListener\OrderPlacedListener">
<argument type="service" id="acme.loyalty.repository.loyalty_account"/>
<argument type="service" id="doctrine.orm.entity_manager"/>
<tag name="kernel.event_listener"
event="sylius.order.post_complete"
method="onOrderComplete"/>
</service>
Extending Admin Menu
// src/SyliusLoyaltyPlugin/Menu/AdminMenuListener.php
namespace Acme\SyliusLoyaltyPlugin\Menu;
use Knp\Menu\ItemInterface;
use Sylius\Bundle\UiBundle\Menu\Event\MenuBuilderEvent;
final class AdminMenuListener
{
public function addAdminMenuItems(MenuBuilderEvent $event): void
{
$menu = $event->getMenu();
$customers = $menu->getChild('customers');
if (!$customers) {
return;
}
$customers
->addChild('loyalty_accounts', [
'route' => 'acme_loyalty_admin_loyalty_account_index',
])
->setLabel('Loyalty Program')
->setLabelAttribute('icon', 'star');
}
}
<service id="acme.loyalty.menu.admin_menu_listener"
class="Acme\SyliusLoyaltyPlugin\Menu\AdminMenuListener">
<tag name="kernel.event_listener"
event="sylius.menu.admin.main"
method="addAdminMenuItems"/>
</service>
API Extension (API Platform)
// src/SyliusLoyaltyPlugin/Api/Resource/LoyaltyAccountResource.php
namespace Acme\SyliusLoyaltyPlugin\Api\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use Acme\SyliusLoyaltyPlugin\Api\Provider\LoyaltyAccountProvider;
#[ApiResource(
shortName: 'LoyaltyAccount',
operations: [
new Get(
uriTemplate: '/shop/loyalty-account',
provider: LoyaltyAccountProvider::class,
),
],
normalizationContext: ['groups' => ['loyalty:read']],
)]
final class LoyaltyAccountResource
{
public int $points = 0;
public array $transactions = [];
}
// src/SyliusLoyaltyPlugin/Api/Provider/LoyaltyAccountProvider.php
final class LoyaltyAccountProvider implements ProviderInterface
{
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
{
$customer = $this->tokenStorage->getToken()?->getUser();
if (!$customer instanceof CustomerInterface) {
throw new AccessDeniedException();
}
$account = $this->repository->findOneByCustomer($customer);
if (!$account) {
return new LoyaltyAccountResource(); // 0 points
}
$resource = new LoyaltyAccountResource();
$resource->points = $account->getPoints();
$resource->transactions = $account->getTransactions();
return $resource;
}
}
Plugin registration in application
// config/bundles.php
return [
// ...
Acme\SyliusLoyaltyPlugin\SyliusLoyaltyPlugin::class => ['all' => true],
];
bin/console doctrine:migrations:diff
bin/console doctrine:migrations:migrate
Testing the plugin
Sylius provides sylius/resource-bundle for PHPUnit tests and Behat for acceptance testing:
// tests/Unit/EventListener/OrderPlacedListenerTest.php
class OrderPlacedListenerTest extends TestCase
{
public function testAwardsPointsOnOrderComplete(): void
{
$order = $this->createMock(OrderInterface::class);
$order->method('getTotal')->willReturn(50000); // 500 rubles = 5 points
$order->method('getCustomer')->willReturn($this->createMock(CustomerInterface::class));
$order->method('getNumber')->willReturn('0000001');
$account = new LoyaltyAccount();
$this->repository->method('findOneByCustomer')->willReturn($account);
$event = new ResourceControllerEvent($order);
$this->listener->onOrderComplete($event);
self::assertSame(5, $account->getPoints());
self::assertCount(1, $account->getTransactions());
self::assertSame('earn', $account->getTransactions()[0]['type']);
}
}
Publishing plugin as Composer package
{
"name": "acme/sylius-loyalty-plugin",
"type": "sylius-plugin",
"require": {
"php": "^8.1",
"sylius/sylius": "^2.0"
},
"extra": {
"sylius-plugin": {
"title": "Sylius Loyalty Plugin",
"description": "Loyalty program for Sylius"
}
}
}
The sylius-plugin type in composer.json allows the plugin to appear in the official Sylius Plugins catalog.







