sylius/variation-bundle
Sylius Variation Bundle adds product variation support to the Sylius e-commerce platform, providing models and logic for variants and options so stores can sell configurable products with different sizes, colors, and other attributes.
First, calibrate expectations. sylius/variation-bundle is an archived, read-only subtree split of a Symfony bundle. It will not install cleanly into a Laravel app, it will not boot through Laravel's service container, and the namespace it ships (Sylius\Bundle\VariationBundle, extending Symfony\Component\HttpKernel\Bundle\Bundle) is inert in a Laravel process. Composer will resolve it only in a throwaway sandbox with --ignore-platform-reqs, and even then you get a dependency tree shaped for Symfony 2/3-era Doctrine. Treat it as a reference implementation that you read and port, not a dependency you ship.
Where to look, in priority order:
src/Sylius/Component/Variation/Model/ — Option, OptionValue, Variant, VariantInterface, VariantAwareInterface. The entire domain model, ~300 lines. This is what you mirror in Eloquent.src/Sylius/Component/Variation/Generator/VariantGenerator.php — the cartesian-product generator. The single most valuable file in the repo.src/Sylius/Bundle/VariationBundle/Resources/config/doctrine/ — XML mappings. Read them for column types and relations; discard the persistence strategy (that's what migrations replace).src/Sylius/Bundle/VariationBundle/Form/Type/ — VariantChoiceType, VariantType. Useful only if you're porting admin selection UI; skip on day one.Your first use case is the canonical one: a product with Size × Color producing four concrete SKUs, each with its own price, stock, and SKU code. Read the model classes, then reproduce them natively:
// app/Models/Product.php
public function options(): BelongsToMany { return $this->belongsToMany(Option::class); }
public function variants(): HasMany { return $this->hasMany(Variant::class); }
// app/Models/Option.php — the axis ("Size")
public function values(): HasMany { return $this->hasMany(OptionValue::class); }
// app/Models/Variant.php — the concrete SKU
public function optionValues(): BelongsToMany { return $this->belongsToMany(OptionValue::class); }
Do not attempt to register the bundle in config/app.php or wrap it in a service provider. That's the trap. The value here is entirely in reading two component classes and re-implementing them; the port is measured in days, not weeks, because the domain is small.
1. The combination-signature pattern. The bundle's generator key insight: every variant has a deterministic signature (the sorted set of option-value IDs it represents), and when regenerating you reuse the existing variant matching a signature rather than creating duplicates. Port this to Eloquent directly:
public function generate(Product $product, array $valuesByOption): Collection
{
$existing = $product->variants()
->with('optionValues')
->get()
->keyBy(fn (Variant $v) => $v->optionSignature());
$kept = collect();
foreach ($this->cartesian($valuesByOption) as $combo) {
$signature = collect($combo)->pluck('id')->sort()->implode('|');
$variant = $existing->get($signature) ?? $product->variants()->create([
'sku' => $this->skuFor($product, $combo),
'price' => $product->price,
]);
$variant->optionValues()->sync(collect($combo)->pluck('id')->all());
$kept->push($variant->id);
}
// Prune variants whose combination no longer exists — but guard this in production.
$product->variants()->whereNotIn('id', $kept)->delete();
return $product->variants()->findMany($kept);
}
The signature function itself lives on the model and is the invariant your generation depends on:
public function optionSignature(): string
{
return $this->optionValues->pluck('id')->sort()->implode('|');
}
2. The cartesian expansion. Keep this pure and testable — it takes option values grouped by option and returns combinations. It's about fifteen lines in the bundle and needs to be the same in your port:
private function cartesian(array $valuesByOption): array
{
$result = [[]];
foreach ($valuesByOption as $values) {
$next = [];
foreach ($result as $prefix) {
foreach ($values as $value) {
$next[] = [...$prefix, $value];
}
}
$result = $next;
}
return $result;
}
3. Schema shape to mirror. Four tables: options (axis name), option_values (belongs to option, has value string), variants (belongs to product, price, stock, sku, option_signature column), and a option_value_variant pivot. The bundle uses Doctrine's many-to-many; in Eloquent it's a belongsToMany on both sides. Persist the signature column as a denormalized cache so you can enforce a unique index on (product_id, option_signature) — that's what prevents duplicate variants under concurrent saves.
4. Wire generation to a domain event, not a controller. Sylius does this via resource-layer events (sylius.product.pre_create etc.). The Laravel analogue is model events or an observer — never call generate() from a form request. That way admin UI, imports, seeders, and API writes all share the same behavior:
Product::saved(fn (Product $p) => app(VariantGenerator::class)->generate(
$p,
$p->options()->with('values')->get()->mapWithKeys(fn ($o) => [$o->name => $o->values])->all()
));
Be careful: only fire when the option axes actually changed, or every $product->save() re-runs generation.
5. Admin selection grid. The VariantChoiceType in the bundle maps to a UI pattern: render each option as a row of checkboxes over its values, then let the generator produce the matrix. This is worth borrowing because it makes the cartesian scope visible to the merchant — they can see "12 combinations" before committing.
6. Pricing and inventory belong on the variant. Read the model's field layout and replicate it: price, original_price, on_hand, on_hold, sku. Catalogue, cart, and warehouse integrations should all key on variant_id, never product_id. This is the architectural decision the bundle encodes and the one that's expensive to reverse.
composer require this package. It's archived, zero dependents means zero field validation, no security patches, and version constraints are frozen at the archive date. If you adopt Sylius proper you get this code inside sylius/product — you never need the split.ProductBundle at Sylius 1.0. What you read in this repo (Option, Variant, OptionValue) is now ProductOption, ProductVariant, ProductOptionValue in the maintained codebase. Keep that mapping in mind if you cross-reference modern Sylius docs.Bundle class extends Symfony's Bundle and its "integration" is kernel registration plus DI. There's no service provider shape to salvage. Your integration work is a clean-room port.Resources/config/doctrine/*.orm.xml are useful for reading intent — do not paste them. Write migrations by hand and add the (product_id, option_signature) unique index the XML doesn't give you.disabled_at instead, or you'll break order-line foreign keys. The bundle's generator reuses rather than recreates, but its pruning is not order-aware.S and L recur across every product's Size axis — the value string is scoped to its option. Never key selection or lookups on the raw value string; always use option_value_id.with('variants.optionValues') is the difference between four queries and four hundred. The bundle's Doctrine mapping doesn't hint at this; your Eloquent port will.How can I help you explore Laravel packages today?