A WordPress site that has outgrown its theme usually gets one of two verdicts: patch it forever, or rewrite it in something modern. Both are expensive guesses made before anyone has measured the problem. When I was asked to move a Miami tattoo studio off WordPress, I did the measuring first, and the number I found changed the whole plan. If you are weighing how to migrate a WordPress theme to plain PHP, or whether to leave WordPress at all, this is the method, with the trade-offs.
The short version: the theme was huge, but the part of it that actually depended on WordPress was tiny. Finding that out took an afternoon and saved weeks.
The number that changed the plan
The Tattoo Panda rebuild started from a site built as a custom child theme on top of a commercial parent theme. It carried thousands of lines of CSS, a 2,500-line home page template and a multi-step booking modal. The studio's owners are artists, and they wanted the design, the animation and the behaviour exactly as they were, on shared hosting where PHP is the only runtime and nothing can be compiled.
Everyone's instinct at this size is "rewrite it". Before agreeing, I catalogued every call the theme made into WordPress. Across every template there were only about 15 distinct WordPress functions, and almost all page content was hardcoded HTML rather than coming from the database.
You can do the same audit in minutes. List every function call in the theme, then check each one against the WordPress function reference:
# distinct function calls across the theme, most frequent first
grep -rhoE '\b[a-z_]+\(' wp-content/themes/my-child-theme --include=*.php \
| sort | uniq -c | sort -rn | head -80
# then keep only the ones WordPress defines: get_header, wp_head,
# esc_html, home_url, admin-ajax handlers, and so on
The output is noisy, but the shape of it is not. A theme that calls wp_query and custom post types in every template is a CMS in disguise, and you should keep a CMS. A theme that mostly echoes hardcoded markup and calls a handful of helpers is a set of PHP files wearing a WordPress costume.
Note
The size of a theme tells you nothing about how much of it depends on WordPress. Count the dependencies, not the lines.
Why I chose plain PHP, and what I rejected
With only about 15 functions to replace, the options changed. This is the comparison I made, and the reason each one lost or won:
| Option | What it would have cost | Verdict |
|---|---|---|
| Rewrite in a PHP framework | Re-learning the design in new templates, new routing, new tooling, for no new capability | Rejected: risk with no benefit |
| Astro or other static generator | A second runtime and a build toolchain the shared host cannot run | Rejected: booking and voting need a server |
| Static HTML | The header, footer and booking modal pasted into every page | Rejected: maintenance trap |
| Plain PHP with Contentful for content | Mechanical removal of WordPress calls from templates that were already PHP | Chosen |
The deciding fact was hosting. The site lives on SiteGround shared hosting, where PHP is the only runtime. Booking, voting and writing to Contentful all need server code, and PHP was already there. The result has no Composer packages, no npm dependencies and no build step, and it deploys over SFTP. If you are weighing this against a full framework or against Next.js, my comparison of Next.js and WordPress for a business site covers the cases where a heavier stack is the right call. Plain PHP wins only when the existing templates are already PHP and the host constrains you.
If you want a second opinion on a migration like this, the WordPress and WooCommerce development service page explains how I scope them.
Make the live site the spec
"Does it look the same?" is not something you can answer by eye across nine pages, and it is the question the client cares about most. So I turned it into a test.
Before changing anything, I captured the rendered HTML of all nine pages and both theme stylesheets as a read-only baseline. Then I wrote a script, verify.sh, that renders every page of the new site locally and compares it with its capture. For each page it reports four things:
- word-level text differences
- CSS class differences
- PHP warnings
- leftover URLs pointing at the old domain
It exits non-zero if any page drifts. The idea, as a sketch rather than the exact script:
#!/usr/bin/env bash
# Compare each locally rendered page with its captured WordPress baseline.
fail=0
for page in home gallery gear contact giveaway booking tattoo-fight privacy terms; do
curl -s "http://localhost:8080/$page" > /tmp/new-$page.html
# strip volatile bits (cache-busting query strings, nonces) before diffing
if ! diff <(normalise baseline/$page.html) <(normalise /tmp/new-$page.html) > /dev/null; then
echo "DRIFT: $page"; fail=1
fi
done
exit $fail
All nine pages were diffed against their originals. The harness is cheap to build, and it turns an argument about taste into a red or green result. It is the part of this project I would reuse on any "identical to the original" job, including ones that move to a different stack. It also keeps you honest about the CSS cascade: the two theme stylesheets stay separate and load in their original order, because merging them changes specificity and breaks things quietly. Each template's large inline style block moved verbatim into its own page stylesheet, with no reformatting or minifying.
Keep old endpoints alive with shims
The most useful trick in the migration is also the least glamorous. The contest page's JavaScript posted to WordPress's admin-ajax.php. I could have rewritten that front-end code. Instead I routed the old path to a small PHP dispatcher that sends each action to its new handler, using Apache rewrites (mod_rewrite docs):
RewriteEngine On
# old WordPress AJAX entry point keeps working
RewriteRule ^wp-admin/admin-ajax\.php$ /api/dispatch.php [L,QSA]
# old giveaway path redirects permanently
RewriteRule ^give/?$ /giveaway [R=301,L]
The browser code carried over unchanged while the server side was replaced underneath it. The same idea covers the contact form and booking endpoints. The same rules also give pretty URLs, a custom 404 and a redirect from the old /give/ path, and keep internal folders away from the web.
If your site also has search rankings to protect, the redirect work is a bigger topic in its own right, and I walk through it in migrating WordPress to Next.js without losing SEO. The principle is the same: never make the old URLs stop answering.
Moving content and forms to Contentful
Artist grids on the home and gallery pages now come from Contentful, so the studio edits its roster in one place. Two decisions are worth copying.
Reads go through a short cache. Reads are cached for five minutes. Cache files are written to a temporary name and then renamed, so a failed write can never leave a half-written cache for the next visitor.
function cache_put(string $path, string $data): void {
$tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp';
file_put_contents($tmp, $data, LOCK_EX);
rename($tmp, $path); // atomic on the same filesystem
}
Writes never trust the browser. The booking form runs the full Contentful Management API flow on the server: upload the reference image, create and process the asset, poll until it is ready, publish it, then create and publish the appointment entry. The browser only ever sends an artist slug. The server maps that slug to the Contentful entry itself, so a tampered value is rejected with a 400, and the write token never reaches the browser.
The voting contest: small, but it still needed real security
The studio runs a public voting contest, backed by three MySQL tables for contests, the leaderboard and votes. This is the part of the site where a casual rewrite would have been dangerous, so it is worth showing what I kept strict.
Votes are protected by email one-time codes. A six-digit code is generated with random_int, is valid for five minutes and is compared with hash_equals so the comparison does not leak timing information. Daily per-email and per-IP limits are enforced before a vote counts. Every query uses PDO prepared statements, and the nomination form checks file type with finfo, caps uploads at 5 MB and sanitises file names. If the database is unreachable, the page falls back to its empty state instead of an error.
$code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
// store a hash of $code with an expiry of now + 5 minutes, then email it
// on submit:
if (!hash_equals($storedHash, hash('sha256', $submitted))) { /* reject */ }
Plain PHP does not mean casual PHP. Removing a framework removes its guard rails too, so each of them has to be rebuilt on purpose.
When this approach is the wrong choice
Plain PHP on shared hosting was right for this one site. It would be wrong in several other situations, and you should say no to it when:
- Content is the product. If editors publish daily, need drafts, roles and revisions, keep WordPress or move to a proper headless CMS with a real front end.
- The theme leans on plugins. If the audit finds forms, memberships, ecommerce and SEO logic living in plugins, the dependency count is far above 15, and the cheaper path may be fixing the site rather than leaving it. For the slow-admin version of that problem, start with the WooCommerce slow admin fix.
- The team will grow. Hand-rolled routing and no tests are fine for one maintainer and awkward for five. A framework gives newcomers a map.
- You have no baseline. Without a way to diff against the original, "identical" is a hope, not a result.
For the bigger question of whether to replace a system or reshape it, rewrite vs refactor for a legacy application gives the framework I use, and the legacy application modernization service page shows where this kind of work fits.
Frequently asked questions
Can I move a WordPress site to plain PHP without losing my design?
Yes, if the theme is mostly hardcoded markup. Keep the stylesheets and their load order as they are, port templates by replacing the WordPress calls, and use a page-by-page diff against captures of the live site to prove nothing moved.
How do I know if a custom theme is small enough to port?
Count the distinct WordPress functions it calls and check how much content comes from the database. Roughly 15 helpers and mostly hardcoded pages is a small port. Custom post types, widgets and plugin hooks in every template is a CMS you should keep.
Will old URLs and forms keep working?
They can, if you route them on purpose. Apache rewrites can send old paths such as the admin AJAX entry point to a dispatcher, and permanent redirects cover moved pages, so existing front-end code and inbound links keep working.
Is Contentful a replacement for the WordPress admin?
For structured content like an artist roster, yes. Editors work in Contentful, and the site reads it through a short cache. It is not a drop-in for plugin-heavy features such as memberships.
Key takeaways
- Audit what the theme actually depends on before choosing a rewrite. About 15 WordPress functions, not 2,500 lines, decided the plan here.
- Choose the stack the host can run. Shared hosting with PHP only made plain PHP the lowest-risk option.
- Capture the live pages and diff against them, so "identical" is a repeatable check that exits non-zero on drift.
- Shim old endpoints instead of rewriting working browser code.
- Rebuild the security rules deliberately when you drop a framework: one-time codes, rate limits, prepared statements and server-side mapping of IDs.
If you have a WordPress site you suspect is smaller than it looks, book a call and I will tell you honestly whether to port it, fix it or leave it alone.
Originally published at aqibjavaid.site.
Top comments (0)