← Blog

Magento

Magento 2.2 inheritance: a 6,400-SKU rescue in Groningen

A 14-person home-goods retailer in Groningen hands you SSH access to a Magento 2.2 box. The last commit is from 2019. The cron has been dead for 18 months. Now what.

Jacob Molkenboer· Founder · A Brand New Company· 7 Jun 2026· 8 min
Open leather logbook with brass key, twine-tied iron tag, green ribbon, red wax seal on ivory blotter.

A retailer in Groningen calls on a Tuesday. Their bookkeeper has been chasing a missing order export for three weeks, the marketing manager cannot push a fresh product feed to Google Shopping, and configurable products sometimes display as €0 on the storefront. The last developer left in 2022. Could we take a look. We get an SSH key over Signal and open the box.

What follows is the kind of triage every studio that touches legacy e-commerce eventually does. We have done versions of this for kitchenware shops, lighting retailers, B2B parts catalogs, and a tile importer. The shape rhymes. The details bite. This one was Magento 2.2, fourteen people on the team, 6,400 active SKUs, and a cron that had been dead since November 2024 without anyone noticing.

The first twenty minutes on the box

The shell prompt tells you most of what you need to know. PHP 7.2 on the app server. MySQL 5.7. Nginx in front. A single VPS at a Dutch host, no staging environment, no load balancer, no read replica. bin/magento --version returns 2.2.7. That release shipped in mid-2018, and Adobe stopped issuing security patches for the entire 2.2 branch in 2019 according to the Adobe Commerce lifecycle policy. Seven years of unpatched code is not a metaphor. It is a CVE list.

We run a quick check: composer show | grep magento. The output is half official packages, half a vendor folder that someone copied straight into app/code/ with no namespace discipline. A directory called app/code/Local/Patches/ contains 11 files dated between 2019 and 2022, each prefixed with a developer initial and a one-line comment. Several patch the Magento\Sales\Model\Order class directly. One patches Magento\Catalog\Model\Product\Type\Configurable to fix a price display bug. None of them are in version control.

We take a snapshot before we touch anything. Always. The first rule of inheriting a legacy stack is that the running system is the only documentation that matches reality, and if you break it you lose the documentation.

ssh root@host "tar czf /root/snapshot-$(date +%F).tar.gz \
  /var/www/html /etc/nginx /etc/php /var/log && \
  mysqldump --single-transaction --routines magento2 | \
  gzip > /root/db-$(date +%F).sql.gz"
scp root@host:/root/snapshot-*.tar.gz ./incoming/
scp root@host:/root/db-*.sql.gz ./incoming/

Two hundred and twelve gigabytes of media, mostly product images at full DSLR resolution because nobody had ever resized them. A 9 GB database. Then we start reading.

What the cron was hiding

The owner mentioned that "some emails stopped working". That is almost always cron. In Magento 2, the entire indexing, queue, customer notification, and stock sync pipeline rides on three cron groups (default, index, consumers). When cron stops, nothing throws an error. The store keeps serving pages. Orders keep landing. Inventory drifts. Indexers go stale. Customer order-confirmation emails queue up and silently expire.

We check crontab -l for the www-data user. Empty. We check for a system cron in /etc/cron.d/. Also empty. The magento cron:install command had never been run, or had been wiped during a server migration. Instead, there was a single line in root's crontab pointing at a wrapper script /opt/magento-cron.sh. The script existed. Its last line was a call to php /var/www/html/bin/magento cron:run. On paper, fine. In practice, the php binary in PATH for root was PHP 8.1, installed last year for an unrelated tool. Magento 2.2.7 refuses to boot on PHP 8.1. The script had been failing every minute since the upgrade, writing a stack trace to a log file nobody read.

Warning

If you inherit a Magento store and the owner says "some things just stopped working", check cron first, the PHP binary in root's PATH second, and the cron_schedule table third. The table will tell you exactly when the silence began.

We query the cron_schedule table for the most recent successful run.

SELECT job_code, MAX(finished_at) AS last_success
FROM cron_schedule
WHERE status = 'success'
GROUP BY job_code
ORDER BY last_success DESC;

The newest success row was from November 14, 2024. Nineteen months of silent failure. The indexers had not run. The customer email queue had grown past 80,000 rows. The sitemap had not regenerated since the same November date, which explained why Google had been quietly dropping product URLs from the index for over a year.

The hand-patched payment module

The retailer used a Dutch payment provider that supports iDEAL, Bancontact, and SEPA. The official module had a known bug in 2020 that caused a race condition on partial refunds. The previous developer fixed it by editing the vendor folder directly. The fix worked. The developer then ran composer update a year later, which silently overwrote half the patch but not all of it. By the time we arrived, the payment flow worked for new orders, broke for partial refunds about 30 percent of the time, and broke completely whenever a customer paid with SEPA on a configurable product purchased through a discount rule.

This is the part where you have to slow down. A patched payment module is not a bug to "fix forward". It is a load-bearing wall. We did three things, in order.

  1. We froze the patched files into git on a branch named archaeology/payment-2020, with a commit message describing the symptom each line addressed (based on git blame against the upstream vendor and the bug tracker of the payment provider).
  2. We wrote a Cypress test that replayed the refund and SEPA failure cases against a staging copy of the store, so we could regression-check any change in under three minutes.
  3. We contacted the payment provider's developer support, sent them the diff, and asked which fixes had landed upstream. Three of the five had. Two had not, and were on their backlog. We forked the module, applied the remaining two patches against the current upstream tag, pinned the fork in composer.json via a satis repo, and documented the upgrade trigger.

The whole exercise took two days. Two days well spent. The most expensive thing you can do with a payment module is touch it without a reproducible test for the failure case.

6,400 SKUs and no source of truth

The catalog turned out to be the hardest part. 6,400 active SKUs spread across 84 categories, four configurable product types, and a custom attribute set for "made in" countries. The retailer also sold roughly 200 of those SKUs through a Bol.com integration, another 50 through a German marketplace, and the rest only through their own storefront. There were three flat files involved in keeping any of this in sync: a nightly CSV from their wholesale supplier, a manual Google Sheet for marketing edits, and a hand-edited JSON file that powered the homepage carousels.

None of them were the source of truth. Each had partial authority over different fields. The CSV controlled stock levels and supplier price. The Google Sheet controlled product name, description, and category mapping. The JSON file controlled which products appeared on the homepage. When the cron died, the CSV import job stopped, and the storefront slowly drifted out of sync with actual warehouse stock. By the time we arrived, 412 SKUs were either oversold or hidden from the storefront despite being in stock.

We did not try to fix the sync logic on the dying Magento. Instead, we built a small Python reconciliation script that read all three sources, the live database, and the warehouse export, and produced a single CSV called drift.csv. The retailer's operations lead could open it in Numbers and see, per SKU, where each system disagreed. That report became the first deliverable.

import pandas as pd

supplier = pd.read_csv("supplier_2026-06-05.csv", dtype=str)
sheet    = pd.read_csv("marketing_sheet.csv", dtype=str)
magento  = pd.read_sql(
    "SELECT sku, qty, price, is_active FROM catalog_state", conn
)

drift = (
    magento
    .merge(supplier[["sku", "qty", "cost"]], on="sku",
           how="outer", suffixes=("_mg", "_sup"))
    .merge(sheet[["sku", "name", "category"]], on="sku", how="left")
)
drift["qty_mismatch"]  = drift["qty_mg"].astype(float) != drift["qty_sup"].astype(float)
drift["missing_in_mg"] = drift["qty_mg"].isna()
drift.to_csv("drift.csv", index=False)

412 mismatches on day one. 38 on day fourteen. By that point the retailer trusted the report enough to use it as the catalog handoff into the eventual replatform.

Patch, fork, or replatform

The honest answer to "should we stay on Magento 2.2 or move" is almost never decided by the technology. It is decided by who can afford to maintain the answer. A 14-person retailer with one marketing manager and zero developers cannot maintain a forked Magento. They cannot maintain a hand-patched payment module. They cannot afford to lose another nineteen months to a silent cron.

What they could afford was a roadmap. We split the work into three buckets.

  • Stabilise the running store. Cron back on a supervised PHP 7.4 binary, sitemap rebuilt, payment module forked and tested, image directory cut down with a one-time resize pass, basic monitoring (Uptime Kuma plus a Slack webhook for cron job failures) added.
  • Extract the data. Reconciliation script in production, daily drift report, a clean export of the 6,400 SKUs and 84 categories into a portable schema. Order history exported to Parquet.
  • Pick the destination. For this retailer, the right answer was a modern headless commerce backend with a thin storefront, not a like-for-like Magento 2.4 upgrade. The catalog was small enough that a Magento upgrade would cost more than starting cleaner.

The patching trap is real. There is a long list of unpatched Magento vulnerabilities still being exploited in the wild, including the pre-auth RCE tracked as CVE-2022-24086 and its bypass CVE-2022-24087, both of which are scanned for daily by automated bots and which the 2.2 branch will never receive an official fix for. If you stay on an unsupported branch you are not "running Magento". You are running an artefact. There is no upstream.

What the rescue actually looked like

Six weeks total. Two days of triage. One week to stabilise (cron, payment module fork, image directory). Two weeks on the reconciliation pipeline. Three weeks on planning and starting the replatform. The retailer kept selling the whole time. Their bookkeeper got her order exports back on day three.

Takeaway

The first job when you inherit a dying store is not to fix anything. It is to find out what stopped working and when, so you can tell the owner the truth about what they have actually been running.

When we built the rescue plan for this client, the part that took the longest was not the code. It was convincing the owner that the patched payment module was a real risk, not a small inconvenience. We solved it by sending him the diff between his vendor folder and the upstream tag, side by side, with the SEPA edge case reproduced on video. He signed the replatform contract the next morning. That is the work we do under legacy migration, and the war story above is one of about thirty similar ones from the last two years.

If you suspect your own Magento install is in this shape, the five-minute audit is this: SSH in, run bin/magento --version, check the last successful row in cron_schedule, and grep your vendor folder for any file modified more recently than your last composer install. Whatever you find is your real starting point.

Key takeaway

When you inherit a legacy Magento store, the first job is not to fix anything. It is to find out exactly what stopped working and when.

FAQ

How can I tell if my Magento cron has actually been running?

Query the cron_schedule table for the most recent row with status = 'success'. If the newest success is more than a day old, your cron has stopped, regardless of what crontab -l shows.

Is it safe to upgrade Magento 2.2 directly to 2.4?

No. The PHP and database requirements between 2.2 and 2.4 differ enough that you have to stage the upgrade through 2.3, and you need every third-party module tested at each step. Plan two to four weeks.

What do I do if I find hand-patched files in the vendor folder?

Freeze them in git on a separate branch, write a test that reproduces what the patch fixes, and only then decide whether to fork the module, upstream the fix, or remove the patch entirely.

Should I keep Magento or replatform?

Decide by team capacity, not by tech preference. If you have no in-house developer, an unsupported Magento install will quietly cost you more than a migration over twelve months.

magentolegacy sitesmigrationphpe-commercecase study

Building something?

Start a project