Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Variation Bundle Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. src/Sylius/Component/Variation/Model/ — Option, OptionValue, Variant, VariantInterface, VariantAwareInterface. The entire domain model, ~300 lines. This is what you mirror in Eloquent.
  2. src/Sylius/Component/Variation/Generator/VariantGenerator.php — the cartesian-product generator. The single most valuable file in the repo.
  3. 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).
  4. 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.

Implementation Patterns

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.

Gotchas and Tips

  • Do not 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.
  • The split is stale on naming. Variation was folded into 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.
  • Symfony bundle ≠ Composer package. The 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.
  • Doctrine XML mappings don't translate to migrations. Column types, nullable constraints, and cascade rules in 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.
  • Signature order is a bug factory. Always sort option-value IDs before joining, and normalize the separator. If two code paths build signatures differently, you'll get silent duplicate variants. Put the signature method on the model and call it everywhere.
  • Combinatorial explosion is a business problem, not a library problem. 4 sizes × 5 colors × 3 materials = 60 variants per product; adding a "finish" axis multiplies again. Enforce caps on axes per product, support sparse variant sets (not every combination is sellable), and consider lazy generation on first catalogue access rather than eager on save. The bundle does not solve this — you own it.
  • Pruning variants deletes history. If a variant has been ordered, do not hard-delete it when its option combination disappears. Soft-delete or mark 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.
  • Option values are not globally unique. 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.
  • Eager-load aggressively. A PDP rendering variants iterates every variant's option values. 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.
  • Extension points you actually get: because this is a reference only, the "extension points" are your own — model observers for generation, a dedicated generator service bound in a provider, and a signature column you control. That's more flexible than what the bundle offers, which is why the port is worth doing.
  • Testing checklist for the port: single-axis (Size only), multi-axis (Size × Color), sparse sets (a missing combination should not be regenerated), idempotent re-save (generation twice = same row count), and pruning with and without existing order lines. If those five pass, you've reproduced the bundle's actual behavior with better ownership.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle
glavweb/extra-bundle
glavweb/api-bundle
glavweb/admin-bundle
adachsoft/api-version-bundle
elsayed85/laravel-api-exceptions
ouhssini/laravel-demo-mode
daworks/ncloud-outbound-mailer-driver
gp247/cms
ignibyte/aic-gates
nexa-merchant/blog
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
cgv/localized-resource