Developing an Online Store on Sylius
Sylius is a PHP e-commerce framework based on Symfony. It's not a CMS with a shopping cart bolted on: the architecture is designed for complex B2C and B2B scenarios. The component structure allows using separate parts (Inventory, Pricing, Promotions) in existing Symfony applications without installing the full stack.
Positioning Against Competitors
Sylius wins when:
- Team works with PHP/Symfony
- Flexibility is needed without SaaS limitations (commercetools, Shopify)
- Multi-channel and multi-currency out of the box are required
- Headless mode via API Platform (JSON-LD / HAL / JSON:API) is important
- Custom workflows through Symfony State Machine are needed
Architecture: Resource Layer
Key Sylius feature — Resource System: all entities (Product, Order, Customer) are managed through a single resource configuration mechanism. This simplifies overriding models and adding custom fields.
# config/packages/sylius_product.yaml
sylius_product:
resources:
product:
classes:
model: App\Entity\Product\Product # your entity, extends Sylius\Product
translation:
classes:
model: App\Entity\Product\ProductTranslation
Sylius automatically updates repositories, factories, and forms for the custom class.
Channels: Multi-Site Support
A channel in Sylius is a sales point with a separate catalog, prices, currency, and domain:
// src/DataFixtures/ChannelFixture.php
$channel = $this->channelFactory->createNew();
$channel->setCode('WEB_EN');
$channel->setName('Online Store English');
$channel->setHostname('myshop.com');
$channel->setDefaultLocale($this->localeRepository->findOneBy(['code' => 'en_US']));
$channel->addLocale($this->localeRepository->findOneBy(['code' => 'fr_FR']));
$channel->setBaseCurrency($this->currencyRepository->findOneBy(['code' => 'USD']));
$channel->setTaxCalculationStrategy('order_items_based');
$channel->setContactEmail('[email protected]');
$channel->setSkippingShippingStepAllowed(false);
$channel->setSkippingPaymentStepAllowed(false);
$this->channelRepository->add($channel);
One Sylius instance serves multiple channels. Each channel sees only assigned products and has its own price list.
Extending Product Model
// src/Entity/Product/Product.php
namespace App\Entity\Product;
use Doctrine\ORM\Mapping as ORM;
use Sylius\Component\Core\Model\Product as BaseProduct;
#[ORM\Entity]
#[ORM\Table(name: 'sylius_product')]
class Product extends BaseProduct
{
#[ORM\Column(type: 'string', nullable: true)]
private ?string $sku = null;
#[ORM\Column(type: 'integer', nullable: true)]
private ?int $weight = null;
#[ORM\Column(type: 'boolean', options: ['default' => false])]
private bool $isBulkAvailable = false;
#[ORM\Column(type: 'decimal', precision: 10, scale: 2, nullable: true)]
private ?string $bulkMinOrderAmount = null;
public function getSku(): ?string { return $this->sku; }
public function setSku(?string $sku): void { $this->sku = $sku; }
// getters/setters for other fields
}
bin/console doctrine:migrations:diff
bin/console doctrine:migrations:migrate
Pricing via Price Group
Sylius supports Catalog Promotions (permanent discounts on product groups) and Cart Promotions (promo codes, rules):
// Catalog Promotion: 20% off Nike brand
$catalogPromotion = $this->factory->createNew();
$catalogPromotion->setCode('NIKE_20_OFF');
$catalogPromotion->setName('Nike -20%');
$catalogPromotion->addChannel($niceChannel);
$scope = $this->catalogPromotionScopeFactory->createNew();
$scope->setType(InForProductScopeVariantChecker::TYPE);
$scope->setConfiguration([
'products' => $nikeProductCodes,
]);
$catalogPromotion->addScope($scope);
$action = $this->catalogPromotionActionFactory->createNew();
$action->setType(PercentageDiscountPriceCalculator::TYPE);
$action->setConfiguration(['amount' => 0.20]);
$catalogPromotion->addAction($action);
Checkout: State Machine
Checkout in Sylius is State Machine with explicit transitions. Standard states: cart → addressed → shipping_selected → payment_selected → completed.
// src/StateMachine/CustomOrderCheckoutListener.php
class CustomOrderCheckoutListener
{
public function preComplete(GenericEvent $event): void
{
/** @var OrderInterface $order */
$order = $event->getSubject();
// Check product availability before completion
foreach ($order->getItems() as $item) {
$variant = $item->getVariant();
if (!$this->inventoryChecker->isReserved($variant, $item->getQuantity())) {
throw new \RuntimeException(
sprintf('Product "%s" is out of stock', $variant->getName())
);
}
}
}
}
# config/services.yaml
App\StateMachine\CustomOrderCheckoutListener:
tags:
- { name: kernel.event_listener, event: sylius.order.pre_complete, method: preComplete }
API Platform: Headless Mode
Sylius 2.0 is integrated with API Platform. Each resource is available via REST and GraphQL:
# GET /api/v2/shop/products
# GET /api/v2/shop/products/my-product-slug
# POST /api/v2/shop/orders
# PATCH /api/v2/shop/orders/TOKEN/items
Authentication via JWT:
# config/packages/lexik_jwt_authentication.yaml
lexik_jwt_authentication:
secret_key: '%kernel.project_dir%/config/jwt/private.pem'
public_key: '%kernel.project_dir%/config/jwt/public.pem'
pass_phrase: '%env(JWT_PASSPHRASE)%'
token_ttl: 3600
// Frontend: get JWT and use
const auth = await fetch('/api/v2/shop/customers/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const { token } = await auth.json();
// Subsequent requests
const products = await fetch('/api/v2/shop/products?channel=WEB_EN&locale=en_US', {
headers: { Authorization: `Bearer ${token}` },
});
Deployment
# docker-compose.yml (prod)
services:
app:
image: myshop:latest
environment:
APP_ENV: prod
DATABASE_URL: "postgresql://sylius:pass@postgres/sylius"
MAILER_DSN: "smtp://user:[email protected]:587"
depends_on: [postgres, redis]
worker:
image: myshop:latest
command: php bin/console messenger:consume async --limit=100
depends_on: [postgres, redis]
Development Stages
| Stage | Timeline |
|---|---|
| Installation, Docker, configuration | 2–3 days |
| Setting up channels, currencies, locales | 1–2 days |
| Custom Entity + migrations | 2–4 days |
| Catalog import | 4–8 days |
| Business logic (promotions, shipping, taxes) | 5–10 days |
| Headless frontend (Next.js + API Platform) | 12–20 days |
| Payment integrations | 3–5 days |
| Total | 29–52 days |







