← Blog

Drupal

Drupal 7 to Medusa 2: a five-week cheese-exporter cutover

A 14-year-old Drupal 7 and Commerce Kickstart 2 webshop, 4,200 douaneklasse-mapped articles, and a nightly Exact Globe export. Here is how we cut over without losing a customs certificate.

Jacob Molkenboer· Founder · A Brand New Company· 17 Jun 2026· 9 min
Open leather customs logbook, brass shipping tag on linen twine, green sticky tab, rubber stamp, ink pad, brass key on ivory paper.

It is Friday evening in Mechelen. The export coordinator at a 24-person specialty cheese house clicks verzend bestelling and stares at a white screen. The customs PDF is half-written. The nightly Exact Globe run starts in two hours. A Drupal 7 module that has rendered EU export certificates since 2012 has just thrown an undefined index notice against a PHP 8.2 backport that nobody remembers applying.

The site is fourteen years old. It runs Commerce Kickstart 2, a distribution that was archived years ago. It carries 4,200 articles, each one mapped to a douaneklasse, each one generating a signed EU export certificate, each one ending up on a fixed-width grootboek line in Exact Globe at 02:00 every night. The cheese leaves Belgium at sunrise. Nothing about that can break for more than a few hours.

This is the five-week playbook we used to move that stack onto Medusa 2 and Astro, with shadow traffic running the whole way, without losing a single customs certificate.

Why move, and why not to a newer Drupal

Drupal 7 reached community end of life on 5 January 2025. There are paid vendors offering extended support, and we have used them before to buy a year. The client had already bought that year. The real problem was not Drupal itself. It was Commerce Kickstart 2, the custom Rules logic, the seventeen contributed modules with no Drupal 10 equivalent, and a TCPDF certificate generator that nobody on the team could read.

A like-for-like rebuild on Drupal 11 was costed. It came in at roughly the same hours as the headless option, with the same data work, but with a CMS the operations team did not need. The site has six editors. Three of them only ever touch product copy. None of them write blog posts. Drupal was carrying weight the business no longer asked it to carry.

Medusa 2 took the commerce engine, Astro took the storefront, and the editors got a small custom admin in the Medusa dashboard for the parts they actually touched.

The data model decision that everything else hangs on

In the old site, the douaneklasse was a taxonomy term referenced from a product variant. Customs documentation lived as a node reference. The export certificate template lived as a fielded node with a TCPDF render hook. None of this is unusual for D7 plus Commerce. All of it is brittle.

Before writing a single line of Medusa code, we modelled the canonical objects on paper:

  • Article: Medusa Product, one per SKU.
  • Douaneklasse: a first-class table, not metadata. Each article links to one customs classification with HS code, weight basis, and origin rule.
  • Export certificate: a generated artifact stored in object storage with a signed URL, linked to an order line.
  • Grootboek line: a derived record, rebuildable from the order at any time.

Putting the customs class in its own table felt like a small thing. It saved us in week four when the auditor asked for the change history of HS code 0406.10.50 over the last three years. Metadata fields do not carry history. A table with a versioned join does.

Week 1: inventory, not code

We did not open a code editor in week one. We sat with the export coordinator and walked through every screen she touched. We watched her print a certificate, hand-correct a typo, and re-save the PDF. We watched her cancel an order that had already shipped a partial pallet. We watched her search for an article by its Dutch name and then again by its old Belgian internal code.

The output of week one was a single Markdown file: 47 workflows, every one mapped to a specific URL on the old site, every one tagged with a frequency. The frequencies mattered. Certificate generation ran 60 times a day. Article duplication ran 4 times a month. We knew where to spend the engineering.

Week 2: the PDF chain

The export certificate is not a marketing PDF. It is a legal document. The Belgian FAVV expects a specific layout, specific fields, and a qualified electronic signature under eIDAS. The old D7 module called TCPDF directly, merged in fields from the order, wrote the file to a local mount, and emailed it. The signing step was manual and done at the end of the week from someone's laptop.

We split that into a clean chain:

// medusa/src/workflows/certificates/issue-export-certificate.ts
import { createWorkflow, WorkflowResponse } from "@medusajs/framework/workflows-sdk"
import { renderCertificatePdfStep } from "./steps/render-pdf"
import { signWithEidasStep } from "./steps/sign-eidas"
import { storeCertificateStep } from "./steps/store"

export const issueExportCertificateWorkflow = createWorkflow(
  "issue-export-certificate",
  (input: { order_id: string }) => {
    const html = renderCertificatePdfStep(input)
    const signedPdf = signWithEidasStep(html)
    const stored = storeCertificateStep({
      order_id: input.order_id,
      pdf: signedPdf,
    })
    return new WorkflowResponse(stored)
  }
)

Each step is idempotent and replayable. The render step uses an HTML template and Playwright. The signing step calls a Belgian qualified trust service provider. The storage step writes to S3-compatible object storage with a deterministic key. If the signing service is down at 14:00, the queue holds the job, the order ships once the signature lands at 14:18, and nobody on the floor notices.

Warning

Do not regenerate certificates on read. The PDF that ships with a pallet must be byte-identical to the PDF stored against the order. We hash the rendered PDF, store the hash, and refuse to re-render if a hash exists.

Week 3: the Exact Globe nightly

The grootboek-export is a fixed-width text file. It has been the same format since 2011. Exact Globe parses it with a hand-rolled Pascal import. Nobody is going to rewrite the Pascal. The file has to come out, character for character, in the same shape.

We wrote a Medusa subscriber that listens for order.completed, materialises the grootboek line, and writes it to a daily file:

// medusa/src/subscribers/grootboek-line.ts
import { SubscriberArgs, SubscriberConfig } from "@medusajs/framework"

export default async function grootboekLineHandler({
  event,
  container,
}: SubscriberArgs<{ id: string }>) {
  const ledger = container.resolve("grootboekService")
  await ledger.appendLineForOrder(event.data.id)
}

export const config: SubscriberConfig = {
  event: "order.completed",
}

At 01:55 every night, a scheduled job rotates the file, validates it against a golden fixed-width schema, and sftps it to the share that Exact Globe watches. The validator is the most important piece. The old site silently truncated long article descriptions to 30 characters. The new site refuses to write the file if any line is malformed and pages the on-call engineer. In five months of running, the validator has caught two issues. Both were our fault. Both were caught before Exact Globe saw them.

Week 4: shadow traffic

This is the week that earns the rest. We put a small Cloudflare Worker in front of the old domain. Every read request was served by the old D7 site. Every read was also mirrored, asynchronously, to the new Medusa and Astro stack on a staging hostname. Responses were stored side by side. A diff job ran every hour.

What we found in week four, in rough order:

  1. VAT rounding on EU exports differs by one cent on roughly 0.4% of orders. The old site rounded per line. The new site rounded the total. We changed Medusa to round per line.
  2. The old search treated brie and brié as different terms. The new search used a Postgres trigram index that collapsed them. The export coordinator wanted them to stay separate, because brié was an internal code for an aged variant. We added an exact-match boost.
  3. The old PDF used Helvetica. The new PDF used Inter. The FAVV portal rejects Inter because the OCR was trained on Helvetica. We changed back.

None of those would have been caught by a checklist. Shadow traffic finds them because real customers exercise the system in shapes nobody documented. We did not write a single new automated test in week four. We wrote three new validators and let the live traffic write the test cases.

Week 5: cutover and the long tail

The DNS flip is the boring part. The TTL had been lowered to 60 seconds three weeks earlier. We flipped the CNAME on a Tuesday at 09:30, watched read traffic shift, and kept the old stack hot. The shadow worker reversed direction: the new stack served, the old stack received mirrored writes for 14 days, and we held order replay scripts ready in case anything went wrong.

Nothing went wrong on the morning of the flip. Three things went wrong in the two weeks after, and all three were boring:

  • A scheduled robot from a Dutch B2B portal was hitting an old XML feed endpoint. We left it on a 301 to the new feed.
  • One large customer had a hard-coded order email going into a Drupal Webform. We routed the email to a parser that pushes into the new Medusa API.
  • Two pre-paid invoices created in the last hour of the old stack had not pushed to Exact Globe. The replay script handled them in 90 seconds.

Five weeks of paid attention, billed at flat fee. A fourteen-year-old shop landed on a stack the team can hire for. The customs table is auditable for the first time in a decade, the editors stopped fighting the WYSIWYG, and not a single export certificate was lost.

What we would tell you to do today

If you are sitting on Drupal 7 with a working webshop, the temptation is to wait. Wait until extended support ends. Wait until a security patch lands wrong. Wait until the developer who knew the customs module retires. Do not wait. Spend a week mapping every workflow on paper, the way we did in week one, before anyone writes code. The map will tell you whether you have a one-month job or a one-quarter job, and it will tell you whether headless is the right answer, or whether a rebuild on the current Drupal is.

When we built the headless cutover for the Mechelen cheese exporter, the thing we kept coming back to was that the customs PDF chain was the load-bearing wall, not the storefront. The storefront was easy. The PDF chain was where the business lived. If you are looking at a similar legacy migration, find your load-bearing wall first.

The smallest thing you can do today: open your old admin, click through one full end-to-end order, and write down every screen you touched. If the list is longer than nine, you have a Mechelen-shaped job.

Key takeaway

On a fourteen-year-old Drupal 7 webshop, the customs PDF chain is the load-bearing wall, not the storefront. Map every workflow on paper before writing a line of code.

FAQ

Why not just upgrade the site to Drupal 11?

We costed it. Same hours as headless because of Commerce Kickstart 2 and 17 contributed modules with no D10 equivalent. Six editors, three of whom only ever touch product copy. The CMS was carrying weight nobody used.

How risky is shadow traffic if writes diverge?

Reads were mirrored. Writes ran only on the old stack until cutover, then only on the new stack with the old stack receiving mirrored writes for 14 days as a safety net. No double-billing, no double-shipping.

How long was the actual cutover in calendar time?

Five weeks. Week one was inventory on paper, no code. Weeks two and three built the PDF chain and the Exact Globe sync. Week four ran shadow traffic. Week five flipped DNS and kept the old stack hot.

What about SEO during the migration?

301 redirects from every old URL to its new counterpart, sitemap submitted on flip day, structured data parity checked against the diff job. Rankings on the top 200 product pages held within one position.

drupalmigrationlegacy sitese-commercecase studyarchitecture

Building something?

Start a project