Technical case study: migrating a live WooCommerce store to a custom PHP and SQLite shop.

WooCommerce to custom store · Technical write-up

Migrating a live store with 3,945 orders, without losing one

A brochure site can go dark for an hour and nobody notices. A shop cannot. This is the engineering side of the Schweitzer Formula rebuild: how 81 WordPress plugins became one PHP and SQLite application, why no price is ever read from the browser, and the six defects we shipped on the way.

Client
Schweitzer Formula, Pennsylvania
Stack
Static HTML, PHP 8, SQLite, Stripe, ShippingEasy, Resend
Scope
WooCommerce to a custom store, live cutover
Live
October 2026
3,945orders migrated with line items, addresses and totals intact
286 → 25database objects, from MySQL tables and views to SQLite tables
0prices the browser is trusted with at any point in checkout
6defects we shipped, and the checks that now catch each one
01

The requirement: it may never stop taking money

Everything below follows from one constraint. This shop takes real orders from real customers every day, and the business is one person who also packs the boxes.

That rules out a number of conveniences. There is no staging-then-swap window where orders pause. There is no maintenance page. There is no acceptable state where an order exists in one system and not the other, because the person reconciling that by hand is the same person who has to ship it.

It also rules out the usual answer to "WooCommerce is heavy", which is a different set of plugins. The install had 81 active and 22 inactive, and 19 of the active ones existed only to patch the page builder. Replacing that with a smaller stack of the same kind of thing would have bought a year.

02

Architecture: a shop, not a CMS

The content pages are static HTML. Only the parts that genuinely need state run PHP, and they share one library and one SQLite file.

Marketing pages, the product guide, the policies: all flat files, served directly by Apache with no process to start. The shop, cart, checkout, order history, dashboard and the webhook receivers are PHP. Both halves call into a single shoplib.php, which owns the database handle, pricing, shipping quotes, coupon evaluation, order creation, email and the integrations.

The data lives in 25 SQLite tables, down from 282 MySQL tables and 4 views. Thirty products are 30 rows, not 219,541 rows of post metadata. SQLite is the right shape here precisely because this is a small shop: one file, no second daemon, atomic transactions, and a backup is a copy. The database sits outside the web root alongside the config, so neither is reachable over HTTP even if a rule is misconfigured.

Product pages are not pre-rendered. /woo-shop/<slug>/ rewrites to product.php?slug=…, so publishing a product in the dashboard makes its page, its catalogue entry and its sitemap line appear together with no deploy. The sitemap is generated from the same query, so it cannot drift from what is actually on sale.

03

Prices are computed on the server, never read from the browser

The cart in the browser is a list of slugs, variants and quantities. It carries no money at all.

Every surface that shows a number, the cart preview, the checkout summary, the Stripe session and the point-of-sale screen, calls the same price_cart(). That function re-reads each product from the database, resolves the variant, applies any live sale, applies wholesale tier pricing only if the session actually unlocked that tier, and returns the lines and the subtotal. The caller never supplies a price and has no way to.

This matters more than it sounds. The common version of this bug is a checkout endpoint that accepts a total from the page and charges it, which means anyone who can edit a form can buy a 128oz bottle for a dollar. Here the Stripe session is built from the server's own line items, and the coupon code is re-evaluated server-side on the way through rather than trusted from the browser. A stale or ineligible code fails the request outright instead of being silently dropped, because silently dropping it charges a different number than the one the customer just read.

Shipping follows the same rule. The quote comes from zone and weight rate tables keyed on the destination state and ZIP, through one shipping_quote(), so a phone order and a web order cannot disagree.

04

Order numbers that cannot collide, and a push that cannot run twice

Stripe can deliver the same webhook more than once. That is not an edge case, it is the documented contract, and the first version of this code did not honour it.

An order is created by a single function that opens a transaction, inserts with INSERT OR IGNORE keyed on the Stripe session id, and checks the affected row count. If the row already existed the function returns without doing anything else. Only a genuinely new row goes on to claim the next customer-facing number. The number is therefore allocated inside the same transaction as the insert, which is what stops two simultaneous deliveries from both reading "next is SF-1042".

The same idea guards fulfilment. Pushing an order to ShippingEasy is keyed on its stored status, and only a successful push blocks a later attempt. An earlier version also treated "queued" as final, which meant a push that failed halfway could never be retried, and the order sat in the dashboard looking fine and never reached the shipping queue.

05

Three signed integrations, verified three different ways

Every inbound request that can change an order is signed, and all three providers do it differently.

Stripe sends a timestamp and one or more candidate signatures. We recompute HMAC-SHA256 over timestamp.rawBody with the endpoint secret and compare against each candidate with hash_equals(), so the comparison is constant time. Events we do not handle get a 200 rather than an error, otherwise Stripe retries them forever.

ShippingEasy signs the request method, the path, the sorted query string and the raw body together. The signature is verified against the real REQUEST_URI, which is why the callback is wired as an Apache rewrite rather than a redirect: a redirect would change the URI and invalidate the signature, and a redirect on a POST would turn it into a GET and lose the body entirely. The same reasoning is why the sitewide canonical redirect we added later applies to GET and HEAD only.

Resend uses Svix-style signing over the message id, timestamp and body. This one is written and is not currently in service, because the signing secret has not been set on the live config, so the endpoint rejects every delivery event. That is a known gap rather than a mystery, and it is the client's call when to close it.

06

The host fought us twice

Both of these cost real time, and neither is in anybody's documentation.

A WAF rule rejected our own return URL. Stripe sends the customer back after payment with the session id in the query string. The obvious parameter name is session_id, and that produced a 406 before PHP ever ran. The host runs the OWASP Core Rule Set, and rule 943110 treats a parameter literally named session_id arriving with an off-domain referer as session fixation. Stripe is, by definition, an off-domain referer. The parameter is now called sid, with a comment in the code explaining why, because the next person to tidy that name will reintroduce the bug.

Server-to-server POSTs to a bare .php URL get challenged. Webhook receivers published as /thing.php were being bounced. The same code published as /thing/ with an index.php inside is accepted. Every webhook endpoint on this site is therefore a directory, which looks like a style choice and is not.

07

Deploying a site that is half static and half PHP

The deploy is a script, not a habit, because the parts that are easy to forget are the parts that break the shop.

It stages a copy first, then transforms it. Root-absolute paths are rewritten for the target prefix, so the same source can serve from a domain root or a subfolder. CSS and JS get a timestamp query appended at that point, which is what stops a browser or an edge cache serving yesterday's stylesheet against today's markup. The .htaccess files are not HTML and so escape the path rewrite entirely, which means the 404 target and the shop's RewriteBase are patched separately by regex. Then it rsyncs.

Three things are excluded on purpose: the private config and database, which live outside the web root, and assets/uploads, which is client-owned content the client edits through the dashboard. A deploy that overwrote that directory would silently delete the banner images David had uploaded that morning.

One Apache behaviour deserves its own warning. A sitewide rule in the root .htaccess does not apply inside a directory that has its own .htaccess with RewriteEngine On. The child replaces the parent's rule set rather than extending it. Our canonical-host redirect tested clean on every top-level page and silently did nothing for every product page, because /woo-shop/ has its own rules for product routing. The fix is to repeat the rules in the child, above its pass-through rule. The way to catch it is to list every .htaccess in the tree and test a URL inside each directory, rather than testing the pages you happen to think of.

08

The cutover

The shop had orders in flight on the day, and the client was mid-way through shipping a batch.

The order export was taken again on launch morning rather than reused from the backup four days earlier, because five more orders had arrived in the meantime and the older file was already wrong. We waited for the client's in-progress shipping batch, around twenty-five orders, to be marked complete before syncing, so no order was half-processed in two systems at once. New order numbering starts deliberately above the historical range, so a new order can never collide with an imported one.

Customer accounts were not migrated as accounts. A large share of the 2,308 records had ordered once, and many had checked out as guests, so a password reset flow would have been support load for no benefit. Order history is a one-time emailed link instead. There is no password to reset and no stored credential to leak.

The complete WordPress site is still on disk: a 140 MB database dump verified by checksum against the server and checked for the end-of-dump marker, plus 3.5 GB of uploads across 22,545 files. A truncated backup looks exactly like a good one until the day you need it.

09

The addresses the sitemap never listed

The redirect map was built from the old sitemap. That was the wrong source, and it took a month to notice.

The sitemap listed pages and posts. WooCommerce had also been generating an archive for every product category, tag, size, bottle material, lid type, cap type and brand, and Google had indexed them. None appeared in the sitemap, so none appeared in the redirect map.

Pulling the indexed page list from Search Console and testing every URL's status code found 56 addresses still returning 404 while earning 2,931 impressions and 86 clicks. They are now covered by 11 prefix patterns rather than 56 literal rules, so archives with no search data yet are caught as well. The file holds 74 rules in total.

Two related defects surfaced in the same pass. The site answered on all four combinations of www and HTTPS with no redirect, splitting ranking signals four ways; that now folds onto one host in a single hop. And the sitemap had never been submitted to Search Console at all.

10

Six defects, and the checks that caught them

All six reached the live site. Four were found by us, two by the client, which is the wrong ratio.

The shipping integration disconnected itself at cutover. The store connection was bound to the WordPress install we had just switched off, so orders stopped reaching the client's shipping software and the API returned 401. Worse, the provider's GET /api/stores omits disconnected stores entirely, so the store appeared to have been deleted. It had not. Caught by the client. An integration authenticated against the old system is now a cutover checklist item, not an assumption.

Checkout displayed a flat $9.99 UPS shipping line. Leftover placeholder copy in the checkout template, while the real zone and weight tables underneath were calculating correctly the whole time. Caught by the client. The lesson is that a hardcoded string survives a backend rewrite without complaining.

The uploads directory was excluded from deploys. Correct for protecting client content, wrong on a first deploy to a new server, where it meant the directory never arrived and the banner 404'd on every page.

Order numbers could be allocated twice. Found by reasoning about repeated webhook delivery rather than by an incident. Fixed with the transactional insert above.

Email delivery stats always read zero. Timestamps arrived from the provider sometimes with a Z suffix and sometimes with a numeric offset, and those do not sort correctly against each other as strings. Storing a unix value alongside the original fixed the window filter.

The cart drawer showed line prices that did not sum to the subtotal. The lines came from the browser's own storage while the subtotal came from the server. Both now come from the pricing endpoint and update together or not at all.

11

Before and after

Before

Platform
WordPress 7.1 + WooCommerce + Divi
Active plugins
81, plus 22 inactive
Database
282 tables + 4 views, 140 MB
Product metadata
219,541 postmeta rows
Order history
account and password
Redirects
none
Canonical host
answered on all four

After

Platform
static HTML + PHP 8 + SQLite
Active plugins
0
Database
25 tables, outside the web root
Product metadata
30 product rows
Order history
one-time emailed link
Redirects
74 rules in the server config
Canonical host
one, single-hop, GET and HEAD only

Homepage HTML is 76,867 bytes, 29.5 KB compressed, with one stylesheet (the web font), one script of our own, and a time to first byte of 0.21 s measured on the live site.

Want the business side? What the rebuild changed commercially: eighty-one plugins replaced by one system, the design, and what we retired rather than rebuilt. Read the business case study →

Working on something with these constraints?

The hard part of a store migration is never the catalogue. It is the fact that money is moving through the thing you are replacing while you replace it, and that every integration you inherit is authenticated against the system you are about to switch off.

If that is the position you are in, we are happy to look at it with you.

Talk to us about your store