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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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