
Written by Kevin Mattutat from SpaceParrots
Shopware to Vendure
What moves from Shopware 6 to Vendure as data, what gets rebuilt as code, and the traps that never raise an error.
- Catalogue
1,514 variants
About a thousand demo products through the Admin API, checked value by value against Shopware's own APIs.
- Order history
306 orders
Moved as finished records, not replayed. Every stored total equals its invoice, within a cent per discount share.
- Passwords kept
151 of 151
Every registered customer logged into the Vendure Shop API with their Shopware password.
- Rule prices
4 in 5
Variants whose Shopware price differs from the base price that moved. The share is demo data; the mechanism is not.
Three parts, and they cost very different amounts
The catalogue moves as data through Vendure's Admin API. Customers and order history move as data too, but no API takes them, so a script running inside Vendure saves them. Everything Shopware calculates at runtime is rebuilt as code: rule prices, Rule Builder conditions, Flow Builder automations, Shopping Experiences layouts, and payment and shipping handlers. That third part is where the project cost sits.
Kevin, a Shopware extension developer, built an open-source migrator for this guide and ran it on Shopware 6.7's demo shop, so the figures and effort ranges come from that run: SpaceParrots/shopware-vendure-migrator.
Moving the data is the easy part. The challenge is thinking through the customisations and edge cases.
Kevin Mattutat · Founder and developer, SpaceParrotsIs this guide for your shop?
A good fit
- Self-hosted or PaaS Shopware 6.7
- Your own developers, or an agency, writing the commerce logic
- Variants, several languages and tax rates
- Some price rules
Not a good fit
- Prices and merchandising run in the Rule Builder, with no developers
- Automation lives in the Flow Builder, pages in Shopping Experiences
- A Shopware major upgrade still to finish: two platform projects in one year
- B2B Components (Evolve and above) in use: employee accounts, roles, quotes and approvals are a project of their own, not a data migration. Vendure Core has none of them; the paid plans add them
Written for developers and tech leads judging whether a Shopware 6.7 shop can make the move, and what it takes. In Vendure each new rule or flow is code; a team without developers would trade tools it uses every day for work nobody on it can do.
Configuration in Shopware, code in Vendure
Kevin has built Shopware extensions since early version 6, but never run a shop. Shopware lets a merchant change prices, shipping, payment and automations without a developer, in the Rule Builder and Flow Builder. Custom work is harder: it runs through Shopware's own data abstraction layer, with its limits, and every storefront component also has to work as a Shopping Experiences block.
Vendure makes the opposite trade: the team builds the shop around its own business model, in code. That suits a model no standard shop fits, such as a marketplace.
- The business logic is yours. Every rule Shopware offers as a no-code tool is a named TypeScript extension point in Vendure: price calculation, tax, shipping eligibility, promotion conditions, the order process (strategies).
- Headless, so the parts scale on their own. The same API serves a web shop, an app or a point of sale, and the backend scales independently of the storefront.
Whether that trade is worth a migration is the merchant's decision. This is the code side of it.
Count before you map
Each of these counts changes the plan. Appendix A has the queries.
| Count | Why it matters |
|---|---|
| Products with and without variants | Either way, each becomes one Vendure product with at least one variant |
| Variants that inherit from their parent | Every inherited field and association has to be resolved before loading |
| Rule prices, per rule and condition type | One piece of code per condition type |
| Customer rows per email, and guest rows | Shopware creates a row per guest checkout; Vendure keeps one customer per email |
| Order, delivery and payment state combinations | Each one that occurs needs a Vendure state |
| Order line items per type | Only product lines become Vendure order lines |
| Canonical SEO URLs | The redirect map is only as complete as this table |
| Shopping Experiences layouts | Each one is rebuilt in the storefront or a CMS |
| Settings overridden per sales channel | The shop runs on the overrides in system_config, not on the defaults |
Then give every plugin and integration a Vendure answer and an owner before the project starts, sorted by what it adds: data, behaviour, or a connection to another system.
The migrator in five stages
- 1
Extract
Every table in one read-only transaction, draft versions skipped.
- 2
Transform
Resolve inheritance, merge customers by email, map each order's states.
- 3
Load
The catalogue through the Admin API; customers and orders through a script inside Vendure.
- 4
Verify
Vendure against the target model, and customers and orders against Shopware's database.
- 5
Compare
The catalogue against Shopware's own Admin and Store APIs.
Every stage can run again on its own, so the cutover can be rehearsed on a copy of production as often as needed. The loader creates and skips but does not yet update changed objects: a real cutover also needs updates and a delta run for the freeze window. After the last load, clean up the job queue and rebuild the search index. Appendix B has the code.

What the API cannot take needs no SQL
The catalogue goes through Vendure's Admin API. Customers and orders cannot: the API takes no password hash, creates no customer without a login, and has no order import, since a draft order reprices every line and takes a new number and today's date.
So a standalone script boots Vendure with bootstrapWorker() and the server's own config (stand-alone scripts), and saves customers and orders as Vendure entities. Custom fields, plugins and price and tax strategies still apply, and nobody writes SQL against Vendure's tables.
Pictured: a migrated product with its "Colour" option group, three variants, and facet values that came from Shopware properties.
The order service would reprice imported orders, and the customer service would hash a new password and publish a registration event. So the loader uses Vendure's services for customer groups and shipping methods, and saves orders and customers as entities through the database connection.
Rebuild the storefront, ideally from a starter
Vendure Core has no built-in storefront, only an API the shop calls, so the Shopware storefront has to be rebuilt. The reference migration did not build one. From the Scale plan, the B2B storefront is included.
You can technically reuse the Twig templates from a PHP app, or move the Vue components into a Nuxt app, and point either at Vendure's API, but every data call still has to be rewritten. The faster way is one of Vendure's storefront starters in Next.js or TanStack Start, where the basic shop already works and you adapt the design.
Plan it as its own workstream: listings, search, cart and checkout, accounts, CMS pages, SEO and transactional mail.
What ported as data, and what got rebuilt
Ported or transformed as data
- Products, variants and option groups
- Properties and manufacturers, as facets
- Categories, as collections with the same tree and order
- Media, translations, base prices and stock
- Tax rates, regrouped into zones
- Customers with passwords, groups and addresses
- Orders as records: amounts, dates, states, payments, fulfilments
- Canonical SEO URLs, as a redirect map
Rebuilt, replaced or retired
- Rule prices: a price calculation strategy
- Rule Builder on shipping, payment and promotions: checkers and conditions
- Flow Builder automations: event subscribers
- Shopping Experiences layouts: storefront or CMS
- Product streams, and the "link only" and "search only" visibility levels
- Payment and shipping handlers, mail templates, number ranges
- Partial shipments and refunds, returns, invoices: kept as an archive
The right column is also what merchandisers lose as self-service. In Vendure a developer writes the condition once; merchandisers then configure promotions, shipping and payment from what exists.

Variants inherit whole associations, not just fields
A variant inherits its tax rate from its parent, and its categories and properties as a whole set. In the reference shop, not one variant had its own.
Read the mapping tables as they are, and every variant lands in zero collections while the load reports success.
Pictured, Shopware: a variant whose name, manufacturer and description come from the parent (link icons).
Rule prices change what most customers pay
For a guest buying one item, more than four in five demo variants cost something other than the base price that moved to Vendure.
Shopware picks the first matching rule that has prices, by priority and then by rule ID. Not the cheapest. A rebuild needs that exact order (Appendix C), or a deliberate, announced switch to simpler prices.
The cutover window, and the last cent
Background jobs set the cutover window, not the API
The catalogue writes took about a minute and queued thousands of search and collection jobs, which took about twenty times as long to drain.
Measure it on your own catalogue, or switch the queue off for the bulk import as the import guide describes.
Order history is records, down to the cent
Shopware keeps separate order, delivery and payment states: 288 combinations, 14 in the demo data, each needing a decision. Partial shipments and refunds cannot be rebuilt.
Shopware also rounds each tax rate's share of a discount separately, so 39 of 306 orders missed their invoice by up to a cent per share. The migrator stores the invoice total, capped at a cent per share.
Quick gotchas: export and import
- Filter every versioned table on the live version, or draft rows end up in the export.
- Read category members from
product_category_tree, notproduct_category: a category page also lists its subcategories' products. - Rewrite Shopware's
$2y$bcrypt prefix to$2b$. Vendure's bcrypt library answers "wrong password" to it without an error. - JavaScript rounds
-16.745to-16.74, PHP to-16.75. Give Vendure a money strategy that rounds halves away from zero. - Migrate newsletter recipients as consent records, with opt-in status and date, including recipients who never had an account.
Quick gotchas: channels, prices and promotions
- A sales channel is not a Vendure channel. Create channels where prices, assortment or tax differ, not one per domain; the storefront picks language and currency per request.
- Shopware calculates a missing currency price from the EUR price and a factor. Vendure has no such fallback, so write the converted prices during the migration.
- Shopware can store a net price that is not gross divided by one plus tax. Vendure stores one price per currency, so count those prices first.
- Unit prices, which German law requires for goods sold by weight, volume, length or area (§ 4 PAngV), need custom fields and storefront support in Vendure.
- Imported orders do not use up promotions, so a one-per-customer code works again. Link the orders to the rebuilt promotion or retire the code.
Effort: days to months, driven by the storefront
Kevin's estimates from the reference migration and his Vendure projects. AI coding agents shorten the writing, not the testing: in both runs, reviews against Shopware's source and data found errors no load error showed.
| Part | Effort | What moves it |
|---|---|---|
| Catalogue | Hours to days | Catalogue size, languages, custom fields |
| Customers and order history | Days, longer than the catalogue | State combinations, discount types, guest accounts |
| Rule Builder and Flow Builder logic | Quick to write, slow to test | Condition types, how exactly prices must match |
| Other plugins and integrations | Code for each one | What the count turns up |
| Storefront | Days to months, usually the largest part | Page scope, CMS, designs, team |
A proof of concept answers most of this for your own shop
- Catalogue: every variant, price, tax category and collection matches Shopware's APIs.
- Customers and a sample of orders: customers log in with their old passwords; totals match the invoices.
- One rule price, as a price strategy: matches Shopware's Store API per customer group and quantity.
- One storefront page with cart: a guest adds a variant at the right price.
- One payment provider in test mode: a test order is placed and paid.
Rehearse locally, cut over from a dump
The reference migration has not gone live and ran on one machine. For a real shop, Kevin would not open both production databases. Rehearse locally instead: restore a Shopware dump, run a local Vendure with the production configuration and version, and verify. For the final run, put Shopware into maintenance mode, take a fresh dump, let the local Vendure write its assets straight to the production S3 bucket, then restore the Vendure database dump on the server. Rehearse into a separate bucket, or every run leaves a full set of images behind.
The reference migration connected no ERP, PIM, payment or shipping provider. Each one is a workstream with its own owner.
Six risks to check
Stale source indexes
Run bin/console dal:refresh:index twice before the final extract, and check it. In the demo shop the listing index was stale and almost no product had an SEO URL.
Redirects
Build them from Shopware's canonical SEO URLs, after the index rebuild.
Background jobs
Measure the queue in a rehearsal, not on go-live day.
Prices
If rule prices are simplified instead of rebuilt, announce every price change before go-live.
Customer logins
Passwords carry over. Accounts bound to different sales channels can share an email: decide which one Vendure keeps. Older legacy_password hashes need a reset.
Order history
Imported orders reserve no stock and send no mail. Refunds need a payment handler with the old method's code. Keep the invoice PDFs as the legal record.
The two schemas side by side
Shopware 6.7 defines 219 core entity tables; most need no Vendure counterpart. 44 are *_translation tables and about 45 hold platform infrastructure a migration does not move. The tables below map what carries merchant data: Shopware as tables, because an extract reads tables, and Vendure as entities, because the Admin API writes entities.
Five rules apply to every Shopware query, whatever the table:
- Filter on the live version (
version_id = 0fa91ce3e96a4bc2be4bd9ce752c3425). - Resolve inheritance before reading a value.
- Resolve translations with Shopware's own fallback order.
- Parse the JSON columns, because that is where prices and payloads live.
- Keep the
BINARY(16)IDs as the identity, not SKU or email.
Four of the counting queries behind the counts above (the hex value is Shopware's constant for the live, non-draft version of a row):
Shopware keeps parents and variants in the same product table. A variant is a row with a parent_id. A NULL in one of its 48 inheritable fields means "use the parent's value", not "empty". Variant-defining options and filterable properties both point at the same property_group_option table, through two different mapping tables.
Vendure puts the two roles into separate entities. Customers buy a ProductVariant, never a Product. Every Product has at least one ProductVariant, and the variant holds the SKU, price, tax category and stock. Variant options (ProductOption) and facets (FacetValue) are separate entities with no relation between them.
productparent rowProductA product without children still gets exactly one variant.productchild rowProductVariantEvery inherited field and association resolved first. Holds SKU, price, tax category, stock.product_optionProductOptionGroup,ProductOptionThe options that define a variant.product_propertyproduct_manufacturerFacet,FacetValueFilters. One property group can become an option group and a facet.product_translationTranslationsOnly the languages actually written; Vendure falls back like Shopware.product_configurator_setting.priceNo counterpartFolded into the variant price.What this means for the transform:
- Parent rows become
Product, child rows becomeProductVariant. A Shopware product without children becomes a Vendure product with exactly one variant. Whether a Shopware parent could itself be ordered has to be read from the data. The reference migration made no parent buyable, and it created exactly the expected 1,514 variants. Shopware's cart does accept a parent, though, and orders reference them (see orders below). - One Shopware property group can become two Vendure objects. "Colour" is a variant option through
product_optionand a filter throughproduct_property. So Vendure gets both an option group "Colour" and a facet "Colour". The migration's binding table therefore keys on source ID plus target role. With the source ID alone as the key, the second write collides with the first. - Vendure core has no manufacturer entity. The reference migration loaded manufacturers as values of a facet called "Manufacturer". A custom field on
Productwould also work. product_configurator_setting.pricehas no counterpart. In Vendure the price lives on the variant, so the transform has to fold any configurator surcharge into the variant price.- A translation moves to Vendure only if it was written in that language. A language that only inherits a value gets no translation row in Vendure. Vendure then shows the default-language text, as Shopware does. A copied row would keep the old text after the default-language text changes.
Inheritance. Vendure has no inheritance between product and variant, so the migrator writes every variant with its own resolved values. Trap 1 shows what happens when inherited associations are not resolved.
Properties become facets. Shopware properties (product_property) and manufacturers were loaded as Vendure facets. The variant-defining options (product_option) became option groups, as described above.
Categories become collections. A Shopware product belongs to a category through a product_category row or a dynamic product_stream, and whether it is shown at all is a per-sales-channel setting in product_visibility with three levels: 10 (link only), 20 (search) and 30 (everywhere). A Vendure collection stores filters instead of members, and the worker computes the member variants in an apply-collection-filters job; visibility comes down to channel membership, enabled and Collection.isPrivate.
The migration writes each category's members as a variant-ID filter. A Shopware category page also lists the products of all its subcategories, because the listing filters on categoriesRo, which Shopware precomputes into the product_category_tree table (ProductListingRoute). The migrator reads members from that table. Its first run read product_category, and its check compared Vendure with the migrator's own model, so parent collections missed their subcategories' products without a failed check. Dynamic product streams need a copy of their members or a rebuild as Vendure filters. Visibility levels 10 and 20 have no Vendure equivalent.
Shopware stores a product's base price as JSON on the product row, keyed by currency, with gross, net and a linked flag. On top of that, product_price holds rule-bound tiers. Each row ties a product, a rule and a quantity range to another price JSON. A rule is a reusable condition tree, used by twelve different associations, from product prices to shipping availability and flows. Tax is a default rate on tax, overridden per country by tax_rule.
Vendure stores one integer price in minor units per variant, channel and currency. Whether that price includes tax is set on the channel (pricesIncludeTax). A tax rate always belongs to one tax category and one zone, and can be narrowed to a customer group. There is no rule entity and no tier table.
product.priceJSON per currencyProductVariantPriceOne integer in minor units per variant, channel and currency.taxdefault rateTaxCategory,TaxRateOne rate per tax category and zone.tax_ruleper-country exceptionZoneCountries grouped by their set of rates: 3 tax categories became 26 zones.product_pricerulerule_conditionNo counterpartNo rule entity, no tier table. A price strategy in code (Appendix C), or flat prices.Two consequences follow:
- Tax needs zones that Shopware never had. Shopware's model is a default rate everywhere, with exceptions per country. Vendure's model is one rate per tax category and zone. The migration grouped countries by their set of rates across all tax categories. The reference shop's 3 tax categories produced 26 zones. The largest, "Tax 19/7/0", holds Germany plus the 222 countries that have no tax rule of their own.
- Rule prices do not migrate as data. The base price carries over exactly. The tiers in
product_pricedo not, because Vendure has no rule to attach them to. Reproducing them takes a customProductVariantPriceCalculationStrategyplus an order-level strategy that re-implement Shopware's rule evaluation. The alternative is a deliberate switch to flat prices. Trap 2 shows how many prices that changed in the reference shop.
A Shopware order has its own state, and so does each delivery and each payment transaction: 4 x 6 x 12 = 288 possible combinations. Line items come in seven types (product, credit, custom, promotion, discount, container, quantity) and can be nested through parent_id. Their product_id is nullable, so a line whose product was deleted keeps its label and price. The order also stores its own rounding configuration.
A Vendure order has one state. Payments, refunds and fulfilments have their own, simpler states. Customer data and addresses are stored on the order as snapshot fields. An OrderLine must point at a ProductVariant. Anything that is not a product line becomes a Surcharge or an adjustment.
orderdelivery statepayment stateOrder,Payment,Fulfillment288 possible state combinations; map the ones that occur (14 here), refuse the rest.order_line_itemtype productOrderLineMust point at a variant. Deleted products get a disabled placeholder.order_line_itempromotion, credit, customSurchargeOne per tax rate, with Shopware’s share.order_transactioncapture, refundPayment,RefundWritten as they were. Nothing is replayed through the state machine.customerone row per guest checkoutCustomer,UserOne customer per email. Guests get no User; password hashes kept, prefix rewritten.order_line_itemdiscount, container, quantityNo counterpartNo mapping. The migrator refuses these orders.What the migrator does:
- Classify line items by type first.
productlines becomeOrderLines.promotion,creditandcustomlines become surcharges, one per tax rate with Shopware's share.discount,container,quantityand nested lines have no mapping, and the migrator refuses such orders. - Every product line needs a variant. Lines for deleted products and for parent products that Shopware sold directly (4 and 13 lines in the reference shop) point at one disabled placeholder variant. The Shopware label and product number go into order line custom fields.
- Map the combinations that occur, not all 288. A table maps each order, delivery and payment triple to a Vendure order, payment and fulfilment state, and refuses the rest. Shopware's partial states record no quantities or amounts. The migrator therefore keeps the last full state: partly paid stays authorised, partly refunded stays settled with no refund, and partly shipped gets no fulfilment. Each is counted as an approximation. The original triple stays in a custom field.
- Write the history as it was. Order number, order date, amounts, payments, refunds and fulfilments come from Shopware. Nothing is replayed through Vendure's state machine, because transitions run business actions such as stock allocation and mails.
A Shopware guest becomes a Vendure Customer without a User; registered customers get both, with their groups and addresses.
- Shopware creates a customer row per guest checkout. One email can own several guest rows and a registered account. The migration keeps one Vendure customer per email, the registered account if there is one, otherwise the oldest guest, and attaches all their orders to it.
- Email is not a unique key once customers are bound to sales channels. Two registered accounts with one email cannot both live in one Vendure channel. The migrator refuses the second, and its orders, with a reason, instead of attaching them to a stranger. The reference shop had none.
- The password hash is in the database, not in the Admin API. Shopware hashes with PHP's
PASSWORD_DEFAULT(CustomerDefinition), bcrypt with a$2y$prefix. Vendure's bcrypt library answersfalseto that prefix without an error, and verifies the same hash as$2b$. The migrator rewrites the prefix and writes the hash into Vendure'sNativeAuthenticationMethod. All 151 registered customers of the reference shop logged into the Shop API with their Shopware password.legacy_passwordandlegacy_encodermark hashes from an older system; those need a custom check or a reset.
How the reference migration was built
The migrator is a Node.js ES module project with mysql2 as its only dependency. Each stage reads the previous stage's files from a snapshot folder and writes its own, so any stage can run again without the ones before it. The code is at github.com/SpaceParrots/shopware-vendure-migrator (MIT licence); the excerpts below come from it, some shortened (column lists, error messages, and lines marked with an ellipsis).
An export that pages through an API for hours reads each page at a different moment. The extract runs every query on one connection inside a read-only transaction with a consistent snapshot. Every query on a versioned table, including every join, filters on the live version, and every query sorts by primary key, so the same database state always gives the same files. Without the version filter, draft versions of products end up in the export.
The manifest records a SHA-256 hash for every file. The transform stage refuses a snapshot whose files are missing or changed, so every later result names exactly which extract it came from.
Two helper functions resolve inheritance. inherit returns the parent's value when the variant's field is NULL. inheritRows returns the parent's rows when the variant has no rows of its own in a mapping table. Both record whether the value came from the variant or the parent. That record showed trap 1: all 535 variants took their categories from the parent.
Media, the cover image, categories, properties and visibilities all inherit as whole associations, so each goes through inheritRows.
Translations follow Shopware's own order, which goes language by language: for the requested language, first the variant's own translation, then the parent product's; then the same for the parent language; then for the system language (EntityDefinitionQueryHelper::buildTranslationChain in Shopware's source). The transform keeps a translation only if it was written in that language, and Vendure's default-language fallback does the rest.
Shopware applies a tax's default rate everywhere, except in countries that have a tax_rule. Vendure needs a rate per tax category and zone. The transform first picks, per country and tax, the rule in force at extract time (active_from), then computes each country's set of rates across all tax categories and creates one zone per distinct set.
For 3 tax categories this produced 26 zones. The transform reports tax rules that apply to a state or a postcode, rules that start in the future and duplicate rules as gaps. It does not merge them into the rate of the whole country.
One Shopware row can become several Vendure objects. A simple product becomes both a Product and a ProductVariant, and a property option can become both a facet value and a product option. The binding key therefore includes the role, which names the kind of Vendure object. The migrator appends every new binding to a journal file before it updates the binding table, and replays the journals on start, so a run after a crash creates no duplicates. It refuses to record an empty id.
Every reference to an object an earlier step created goes through a need() check. Without it, a missing binding would become undefined, which JSON quietly drops: the dependent object would be created without it, bound, and never repaired. With it, the dependent object waits for the next run.
A Vendure collection computes its members from filters. By default, a child collection also inherits its parent's filters, which narrows it to the intersection. The loader writes each category's members as a variant-ID filter and switches filter inheritance off. The members come from Shopware's listing index (product_category_tree), so a parent collection contains the products of its subcategories, as the Shopware category page does.
The model keeps Shopware's sibling order, which Shopware stores as a linked list through after_category_id.
For the catalogue, the verify stage compares Vendure with the migrator's own intermediate model, so it only proves that the load stage wrote the model correctly. The compare stage (oracle.mjs) checks against Shopware instead. Shopware's Admin API with the sw-inheritance: 1 header returns the inherited values as Shopware resolves them, which checks the transform stage. Shopware's Store API returns the price a guest pays. That price is in calculatedPrices, not in calculatedPrice:
My first comparison read calculatedPrice, found all 1,513 prices the Store API returned equal, and reported no price gap. Anyone who validates a migration against Shopware's Store API needs to read calculatedPrices instead.
For customers and orders, verify-sales reads Shopware's database again rather than the migrator's model: order totals, tax per rate, dates and owners, plus a Shop API login for every registered customer.
Customers and orders do not go through the Admin API. The loader is a standalone script: it boots Vendure from the target's own config with bootstrapWorker(). It starts no HTTP server, and no job queue unless the script calls startJobQueue(). Services come from the application's dependency injection container:
Each customer and each order is one transaction and publishes no events: no registration or order mails, no history entries. The customer groups, shipping methods and placeholder product go through Vendure's services, though, and those publish events. The target's subscribers run inside the script, and any job they queue waits for the server's worker. After the load, cancel queued jobs that should not run for historical data, such as syncs to an ERP, remove the settled ones, and rebuild the search index. The config has to carry custom fields for the Shopware ids, the original order states and the legacy line labels. Vendure's docs create the context with RequestContextService.create(), with a superadmin user where a service checks permissions; the context built directly here worked for the calls above in the reference migration.
The stored order totals come from Vendure's own OrderTaxCalculationStrategy, so the dashboard's lines and totals agree. Where the result misses Shopware's invoice, the invoice total is stored, but only within one minor unit per surcharge:
A first version stored the invoice total at any difference. Its totals check then compared that same figure with Shopware and could never fail. verify-sales now also adds up each order's lines, surcharges and shipping, independently of the stored total.
Rule prices as a Vendure price strategy
Sketch, not run. The code in this appendix type-checks against @vendure/core 3.7.3. It is not registered in a Vendure server, has never run, and has no tests. Several condition evaluators throw not implemented. It shows the parts an implementation needs.
Read from the Shopware 6.7 source (RuleLoader, CartRuleLoader, ProductPriceCalculator):
- All rules are loaded in the order
priority DESC, id ASC. - The rules matching the current customer and cart are kept in that order. Matching is repeated until the set is stable, because prices change the cart total and the cart total changes which rules match.
- The first matching rule that has prices for the product wins. Not the cheapest.
- Within that rule, the tier covering the line quantity applies.
- No matching rule with prices means the base price applies.
Shopware: rule prices on a product, with a quantity tier for the "All customers" rule.
The reference shop shows why the order matters. Seven of its twelve rules had priority 100, so their IDs decided the price. For 1,289 variants the charged price identified the winning rule, and priority DESC, id ASC predicted it for all of them; 17 products whose price matched more than one rule were left out of that test.
Vendure has no rule engine. ProductVariantPrice holds one price per variant, channel and currency. Conditional prices therefore become two strategies: one for catalogue prices, one for cart prices.
Rules and their prices get their own tables. The Shopware rule ID is kept, because it breaks ties between rules with equal priority.
One evaluator per Shopware condition type in use; the reference shop uses the six types below. A condition type without an evaluator must stop the import. It must never count as a match.
The catalogue strategy sets listing and product page prices at quantity 1 with an empty cart. The order strategy sets the price per order line with its quantity. Each strategy wraps the strategy configured before it and calls that strategy when no rule price applies. Vendure's tax calculation therefore still runs for every price. Extending Vendure's default strategy is not possible, because @vendure/core does not export it.
init() and destroy() are omitted here. Because the wrapped strategy is no longer in the config, the wrapper has to call the inner strategy's init() itself.
- Cart-amount conditions. Shopware repeats rule matching until the result is stable. Vendure prices each line inside a single recalculation, so the sketch evaluates cart conditions against the totals from before that recalculation. A cart that crosses a threshold can get a different price than in Shopware. Either accept and document it, or run a second pass when the matched rule set changes.
- Weekday.
dayOfWeekhas to use the shop's timezone, not the server's. - Listings. The catalogue strategy runs once per variant on every listing, so prices have to be loaded in batches per page, not per variant.
- Simpler prices. A team can also replace rule prices with fixed channel prices or customer-group prices. That is a business decision, and the team should record it as one.
The strategy is done when it returns the same prices as Shopware in every test context. For each test case, record the price that Shopware's Store API returns. Then check that the resolver returns the same amount in minor units. The expected values come from Shopware, never from a calculation by hand.
| Fixture | Why |
|---|---|
| Two rules tied at priority 100 | The rule ID order decides |
| Quantity 9, 10 and 11 | Tier boundaries |
| Customer with a US billing country | Country conditions |
| A Sunday | A low-priority weekday rule must lose to any matching priority-100 rule |
| Product without rule prices | Falls back to the base price |
| Cart crossing a cart-amount threshold | The known deviation, measured instead of assumed |

Kevin Mattutat
Founder and developer, SpaceParrots
Kevin Mattutat runs SpaceParrots, a software company in Berlin that builds commerce backends on Vendure for retail clients. He has been a Shopware Extension Partner since 2020 and sells a statistics extension in the Shopware Store. For this guide he ran a reference migration of Shopware's demo shop into Vendure, catalogue, customers and orders, and published the migrator, including the parts that did not migrate as data.
The author's views are their own and may not reflect the views of Vendure.
Planning the move?
Bring your Rule Builder rules, your Flow Builder automations and the plugins your Shopware build made you write. A partner, or our own engineers alongside your team, will sort them into what moves as data and what gets rebuilt as code before anyone writes code.

