← Blog

Joomla

Joomla to Shopware migration: how a K2 field broke 9,200 SKUs

Day seventeen of a Joomla-to-Shopware freeze, a Zaltbommel furniture maker is staring at a product page with 412 fabric variants. None of them buy anything. This is why.

Jacob Molkenboer· Founder · A Brand New Company· 21 Jun 2026· 10 min
Open leather logbook, brass key on cream card, frayed shipping tag with green ribbon, ink pad on ivory paper.

Day seventeen of the freeze, the founder of a Zaltbommel furniture manufacturer is standing behind a dealer's laptop in our staging area. The screen shows a Shopware 6 product page for a made-to-order lounge chair. There are 412 variant options on it. They are all colour codes. None of them buy anything.

This was not a Shopware bug. This was a 2014 K2 extension talking to a 2026 importer through eleven years of well-intentioned customisations.

The brief, briefly

The brief was simple, on paper. Move off Joomla 2.5, which has been end-of-life since the end of 2014. Move off PHP 5.6. Replace VirtueMart 2 with Shopware 6. Put Vue Storefront in front of it so the dealer-catalogus would stop feeling like a 2009 PDF in a browser frame. Keep all 9,200 made-to-order configurations intact. Twenty-five people use this every day, 180 dealers across the Benelux push orders through it, do not break it.

The catalogue is the company. Every chair, sofa, and lounger has a base SKU, a frame option, six or seven cushion options, and one NCS colour code per stofstaal (fabric swatch). The colour codes are not optional. The dealer picks one, the order generates a cutting list for the sewing room, the cutting list goes to the laser. Wrong code, wrong fabric, six weeks of rework.

What we found in the export

The export from VirtueMart 2 was tidy enough. Products, variants, prices, stock. Readable CSV, defensible.

The fabric data, though, lived nowhere we expected. Eleven years ago, somebody on the team had built a K2 item type called stofstaal. K2 was the workhorse content-construction kit for Joomla 2.5 back when Joomla 3 was still rough at the edges. The dev had wired the fabric metadata into a custom K2 extra field. One field per swatch set, but the value was a string like this:

NCS-S-1500-N\tNCS-S-2502-Y\tNCS-S-3010-R10B\tNCS-S-4040-G30Y…

Tab-separated. Sometimes 4 codes, sometimes 60. Stored as one TEXT field in the #__k2_attribs table, JSON-wrapped at the K2 layer, but a tab string at the row level.

A PHP 5.6 helper somewhere on the Joomla side parsed that string at render-time and matched each code against a separate VirtueMart custom field on the parent product. The dealer-catalogus UI then drew a swatch grid. None of this was documented. The helper file was called stofstaal_v3_final_FINAL.php.

How Shopware ate the field

Shopware 6 has a property importer that does roughly the right thing for variant data. You hand it a product, you hand it a list of property values, it generates variants. We pointed it at the migrated export. The export still contained the K2 field, still tab-separated, because nobody had told the migration script that the tabs meant "list of swatch references" and not "list of variant options".

Shopware did what Shopware does. It saw a multi-value field. It treated every tab-separated chunk as a property value. For a sofa with 60 swatches, that produced 60 colour variants, multiplied across frame and cushion options. One product blew up into roughly 14,000 variant rows. The whole catalogue blew up into 9,200 broken configurations and about 1.7 million variant rows.

The Shopware admin choked on the product detail page. The Vue Storefront PDP rendered a colour grid the size of a small swimming pool. Dealers could not place a single order.

The seventeen days

Days 1 to 3. We thought it was a Shopware property-cleanup issue. We wrote a migration to deduplicate property values, normalise NCS codes, collapse whitespace. It changed nothing structural. The variants still existed.

Days 4 to 6. We suspected the importer config. We rebuilt the import pipeline twice, once with the entity repository directly, once with the Sync API. Same blowup, slightly faster.

Day 7. We finally read stofstaal_v3_final_FINAL.php. The helper had a comment near line 240:

// LET OP: tabs zijn pointers, geen waardes

stofstaal_v3_final_FINAL.php, 2014

Watch out: the tabs are pointers, not values. That comment cost us six days.

What the data actually meant

A stofstaal row in K2 was not a colour. It was a reference to a physical fabric sample the company sends to dealers. Each sample has an NCS code, a price tier, a lead time, and a supplier. The tabs in the field were a packed reference list: "this sofa can be made with any swatch in this set." The set was the entity, not the codes.

In a clean data model, the swatch is a separate resource and a product has a many-to-many relationship to swatches. In the 2014 K2 model, the swatch was a content item and the relationship was a tab-string in an attribs field. Shopware's importer cannot infer that. No importer can.

The fix

We split the migration into two passes.

Pass one, swatches. We pulled every K2 stofstaal item into a Shopware custom entity called swatch, one row per NCS code, with the price tier and supplier metadata attached. Custom entities are exactly what they sound like: first-class objects in the Shopware data model that ride along with products without polluting the variant axis.

Pass two, the link. Each product got a many-to-many association to swatches, expressed in the admin as a "fabric availability" tab. The Vue Storefront PDP queried the association through the Store API and rendered the swatch grid client-side, with a colour picker that filtered by price tier.

The variant axis stayed for what it was actually for: frame and cushion options. Eight to twelve real variants per product, not fourteen thousand fake ones.

The extractor for pass one looked roughly like this. Simplified, but this is the shape:

<?php
// joomla-side extractor, run once against the old DB

$pdo = new PDO('mysql:host=legacy;dbname=joomla25', $user, $pass);
$rows = $pdo->query("
    SELECT i.id, i.title, a.value
    FROM jos_k2_items i
    JOIN jos_k2_attribs a ON a.itemID = i.id
    WHERE i.catid = 17  -- stofstaal category
")->fetchAll(PDO::FETCH_ASSOC);

$swatches = [];
foreach ($rows as $r) {
    $codes = preg_split("/\t+/", trim($r['value']));
    foreach ($codes as $code) {
        if (!preg_match('/^NCS-S-/', $code)) continue;
        $swatches[$code] = [
            'ncs_code'   => $code,
            'source_id'  => (int) $r['id'],
            'source_set' => $r['title'],
        ];
    }
}

file_put_contents('swatches.json', json_encode(array_values($swatches), JSON_PRETTY_PRINT));

The Shopware side then imported swatches.json through the Admin API into the custom entity, and a second script walked the product table to build the many-to-many links from the same source data.

Once the swatch entity existed, the property importer stopped seeing colour codes as variants because we stripped the field from the product import payload entirely. The blowup vanished. Variant counts dropped from 1.7 million to about 84,000 across the catalogue, which is the real number for a 9,200-config made-to-order line.

What we wish we had done on day one

Two things.

First, a one-page rendering audit. Before migrating a single row, read every line of code that touches the legacy data on the way to a pixel. Not the database. Not the admin UI. The render path. If a comment in a helper file would have saved six days, that helper file was the most important file in the project, and we did not read it until day seven.

Second, a sample-of-one dry run. Pick the most complicated single product. Migrate only that. Render it in the new stack. If it looks wrong, you have a problem with one product. If you migrate 9,200 first and then look, you have a problem with the project.

A note on Joomla in 2026

Joomla 2.5 has been unsupported for more than a decade. The Joomla project's own version matrix has been clear about this since 2014. Joomla 4 and 5 are healthy, modern stacks. The problem is rarely Joomla itself. The problem is the 2014 extension graveyard that grew on top of it: K2, JCE, JomSocial, RokSprocket, Sourcerer, dozens of others, half abandoned, half forked, all storing critical business data in fields nobody documented.

If you are running Joomla 2.5 or 3.x in 2026 with more than five third-party extensions, your migration is not a Joomla migration. It is an archaeology project, and you should price it like one.

Takeaway

The hardest part of a legacy migration is not the database. It is the undocumented PHP helper that gives the database meaning.

What this cost, in plain numbers

Seventeen days of project delay. About 90 person-hours of debug time that should not have existed. One Friday-evening rollback rehearsal that we did not need to use, which was the only piece of good news in the window. The client went live three weeks late and shipped clean. No dealer order was lost. The sewing room never got a wrong cutting list. We count that as the win.

When we rebuilt the dealer-catalogus for this Zaltbommel manufacturer, the thing we ran into was a CCK field that lied about its own shape. We solved it by treating the rendering code as the canonical schema, not the database. That habit is now the first day of every legacy migration we take on.

The five-minute audit you can run today

Open your legacy CMS. Find one custom-field, attribs, or "extra" column on your most important content type. Grep your codebase for the field name. Read every file the grep returns. If the field is parsed, split, exploded, or regex-matched anywhere in render code, write down what the parser expects. That document is the spec your migration needs. Nothing else is.

Key takeaway

The hardest part of a legacy migration is not the database. It is the undocumented PHP helper that gives the database meaning.

FAQ

Can you migrate Joomla 2.5 directly to Joomla 5?

Not in one jump. The official path is 2.5 to 3.10 to 4.x to 5.x. With 2014-era extensions in the mix, a clean re-platform is usually cheaper than chaining four upgrades.

Why pick Shopware 6 over Magento for a B2B dealer catalogue?

Shopware 6's custom-entity model and B2B suite handle dealer-specific pricing and many-to-many product metadata without plugin sprawl. Magento can do it, but the total cost of ownership is higher.

How do you avoid the variant-explosion problem on import?

Strip non-variant fields from the product import payload entirely. If a value is not a frame, size, or finish, model it as a custom entity or property and link it after the fact.

What does K2 do that core Joomla didn't in 2014?

K2 added a configurable content-construction kit with extra fields, item types, and category templates years before Joomla core caught up. It is also where most undocumented legacy business data hides.

joomlamigrationlegacy sitese-commercecase studyphp

Building something?

Start a project