sylius/pricing-bundle
Sylius PricingBundle provides the pricing engine for Sylius e-commerce applications. It resolves and calculates product variant prices using configurable price providers, calculators, and context, with support for channel-specific pricing and custom pricing logic.
First, confirm you should be here at all. The bundle is archived, Symfony-only (DI bundle + compiler passes + Doctrine mappings), and has been folded into sylius/sylius core. In a Laravel app you will never composer require it successfully on PHP 8.2+ / Laravel 10–12 without --ignore-platform-reqs, which is not a production posture. The realistic entry point is read-and-reimplement, so budget an afternoon for reading and a week for owning the result.
Read in this order (the codebase is small):
Pricing/PriceResolver.php — the delegating resolver that maps a subject's class to a concrete resolver via a registry.Pricing/PriceCalculator.php — the delegating calculator that does the same for calculations (discounts, taxes, custom rules).Model/PriceInterface.php / the price entity — this is your schema spec: amount + currency, tied to a priceable subject.DependencyInjection/SyliusPricingExtension.php — the only source of truth for real config keys; the README drifted.The core insight to carry into Laravel is the separation of concerns: a price provider returns stored base prices for a subject+context; a calculator transforms a price (discounts, taxes); a resolver picks the right pair for the object it is handed. That's roughly 200–300 lines of logic to reimplement natively.
First Laravel use case — resolve the effective price for one purchasable subject:
// app/Contracts/PriceResolver.php
interface PriceResolver
{
public function supports(object $subject): bool;
public function resolve(object $subject, PricingContext $context): ?Money;
}
// app/Services/Pricing/ChainPriceResolver.php
final class ChainPriceResolver
{
/** @param iterable<PriceResolver> $resolvers */
public function __construct(private iterable $resolvers) {}
public function resolve(object $subject, PricingContext $context): Money
{
foreach ($this->resolvers as $resolver) {
if (! $resolver->supports($subject)) {
continue;
}
if ($price = $resolver->resolve($subject, $context)) {
return $price;
}
}
throw new UnresolvablePrice($subject, $context); // never silently return 0
}
}
Bind the chain in a service provider and register concrete resolvers with a tag. Your first end-to-end test should be a single variant, single currency, base price only — then layer channel overrides, then tiered/customer-group, then discounts.
Ordered resolver chain, not discovery order. The bundle's registry implies priority; make it explicit. In Laravel use a tagged collection sorted by a priority() method or a config array, and iterate deterministically. Most-specific-rule-wins is the policy stakeholders expect (contract price > customer-group tier > channel override > base price).
Provider vs. calculator split. Keep them separate exactly as the bundle does:
(priceable, currency, channel) → base price row.base − discount + tax. Order them explicitly in the contract (convert currency once, then discount, then tax) and test that order.Context as a value object. The bundle's "multiple currencies and custom pricing contexts" maps to an immutable DTO you pass everywhere:
final class PricingContext
{
public function __construct(
public readonly string $currency, // ISO 4217
public readonly ?int $channelId,
public readonly ?int $customerGroupId,
public readonly int $quantity = 1,
public readonly ?CarbonImmutable $at = null,
) {}
}
Eloquent data model. Don't port Doctrine mappings. One prices table keyed by (priceable_type, priceable_id, currency_code, channel_id, price_type, min_quantity), amounts stored as integer minor units. Add a price_version counter bumped on any mutation for cache keys.
Pipeline for calculators. For multi-step calculation, Illuminate\Pipeline\Pipeline gives you explicit ordering and short-circuiting; for the resolver chain a simple sorted loop is clearer.
Wrap the legacy service in a decorator if any old code still calls sylius.pricing.price_resolver during migration — delegate to the new chain so there's exactly one price path.
Shadow-mode cutover. Behind a feature flag, compute both the old and new price for every request, log mismatches, and only flip when the diff is clean for a defined window. Pricing regressions surface via finance and customers, not tests.
Cache aggressively, invalidate by version. A resolver result cache keyed by (subject, currency, channel, quantity, price_version) is cheap to add and essential at catalog scale.
sylius/registry — none of it ports. Only the idea ports; translating the Symfony shape into Eloquent/pipeline idioms by inertia is the classic failure mode.0, you render $0.00 line items. Fail loudly in non-production, log-and-alert in production. Never default to zero on the price path.SyliusPricingExtension; never trust docs, read the extension's load() and the service definitions it wires.ServiceProvider and container tags.How can I help you explore Laravel packages today?