This site you're reading right now is the end result of a WordPress to custom PHP migration I did on josephcharnin.com. I migrated it because WordPress was the wrong tool for a developer's blog that I fully control — the runtime overhead, the plugin dependency chain, and the inability to structure the application exactly as I wanted were all friction I didn't need to carry. The migration went smoothly from an SEO standpoint: no rankings lost, all URLs preserved, page load time dropped from 1.8 seconds to under 400ms on a warm server. Here's the complete process, including the parts where I had to be careful not to break things.
When NOT to Migrate (Stay on WordPress If...)
Before the methodology, the honest caveat: WordPress is the right tool for a lot of situations, and migrating away from it has real costs. Stay on WordPress if any of these apply:
- Non-developers are publishing content. WordPress's editor is genuinely good. Custom PHP requires either a CMS layer (which largely defeats the purpose) or a developer for every content change.
- You rely on WooCommerce. Re-implementing e-commerce functionality in custom PHP is a multi-month project that almost certainly isn't worth it compared to the cost of running WooCommerce.
- Your site has complex multi-author workflows, user registration, or role-based content gating. WordPress handles these well out of the box.
- You're not comfortable with PHP routing, URL rewriting, and server configuration. The migration itself requires real backend knowledge.
My site is a static-ish developer blog. The content is authored by one person (me), the structure is simple, and I wanted full control over performance and the codebase. That's the right use case for this migration.
Exporting Content via the WP REST API
Don't use the XML export file for content migration. It's messy, the serialized PHP in the meta fields is a parsing nightmare, and images aren't included. Use the REST API instead — it gives you clean JSON with all the fields you need:
curl "https://yoursite.com/wp-json/wp/v2/posts?per_page=100&page=1" \
-H "Authorization: Bearer YOUR_APP_PASSWORD" \
> posts-page-1.json
I wrote a PHP migration script that paginated through the REST API and wrote each post to a structured array in the format my custom application expected. The fields I extracted: id, slug, title.rendered, date, modified, content.rendered, excerpt.rendered, featured_media (image ID), categories, and yoast_head_json (for meta descriptions if you're using Yoast SEO).
The content.rendered field gives you the post content as HTML, already processed through the block editor or classic editor renderer. For a simple blog this is exactly what you want — you can store it directly and render it in your custom template without any further processing.
Mapping WP URL Structure to a Custom Router
WordPress's default permalink structure is / or / depending on your settings. My custom router needed to replicate whatever structure was live in WordPress to avoid breaking inbound links.
The router maps URL patterns to handler functions. For a category/slug structure:
// index.php — front controller
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$uri = rtrim($uri, '/');
$segments = array_filter(explode('/', $uri));
match(count($segments)) {
0 => render_home(),
1 => handle_single_segment(array_values($segments)[0]),
2 => handle_category_post(...array_values($segments)),
default => render_404()
};
The handle_category_post($category, $slug) function looks up the post by slug (category is validated but the slug is the primary lookup key), loads the content file, and renders the post template.
On Plesk or Apache, a simple .htaccess sends all non-file requests through the front controller:
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php [QSA,L]
Preserving Canonical URLs and Handling Redirects
URL preservation is the most critical SEO step in the migration. Every URL that has inbound links, is indexed by Google, or appears in your sitemap must either resolve correctly on the new system or return a 301 redirect to its new location.
If you're keeping the exact same URL structure (same permalink format), you just need to ensure your router handles the same patterns. Where you need 301 redirects is for:
- URLs that changed format (e.g., you had
/2024/10/post-slug/and you're moving to/category/post-slug/) - The WordPress default pages:
/wp-login.php,/wp-admin/,/feed/— these should 410 (Gone) rather than 404 - Tag archive pages:
/tag/billing/if you had them and they ranked
I built a redirect map as a PHP array and handle it before the main router:
$redirects = [
'/old-category/old-post-slug/' => '/new-category/old-post-slug/',
'/2024/10/billing-renewals/' => '/storage-billing/how-to-manage-storage-billing-software-renewals/',
];
if (isset($redirects[$uri])) {
header('Location: ' . $redirects[$uri], true, 301);
exit;
}
Set the canonical tag on every page to the authoritative URL. This prevents duplicate content issues if the same post is accidentally reachable via multiple URL patterns during the transition period:
<link rel="canonical" href="https://josephcharnin.com<?= htmlspecialchars($post['canonical_path']) ?>" />
Migrating Images from wp-content/uploads
WordPress stores uploaded images at /wp-content/uploads/YYYY/MM/filename.jpg. You have two options: keep images at those exact paths (easiest, no URL changes needed) or move them to a new path structure and set up redirects.
I kept the images at their original WordPress paths by simply copying the wp-content/uploads directory to the same location on the new server. Since the URL path didn't change, every <img> tag in the migrated post content and every indexed image URL continued to work. This is the right approach unless you have a specific reason to reorganize your image directory.
For the featured images, I extracted the image URL from the WP REST API's _embedded['wp:featuredmedia'] field during the migration script run and stored the full path in the post data. No rewriting needed.
Session and User Management Without WordPress
My site is a public blog with no user authentication, so this was trivial. For sites with logged-in users, you need a replacement for WordPress's user system. The approach I use for custom PHP apps is PHP sessions with a custom users table, bcrypt password hashing via password_hash(), and a simple auth_tokens table for "remember me" functionality. Nothing complex — WordPress's authentication is not particularly sophisticated under the hood, and a lightweight custom implementation is straightforward to build.
Performance Gains: Real Numbers
On the same Plesk shared hosting account, with no other changes:
- WordPress (no caching plugins): 1.8s TTFB, 2.4s fully loaded
- WordPress (W3 Total Cache, warm cache): 320ms TTFB, 900ms fully loaded
- Custom PHP (no caching layer): 85ms TTFB, 380ms fully loaded
The custom PHP implementation with no caching outperforms WordPress with aggressive caching enabled. The reason is simple: WordPress bootstraps a plugin chain, loads a theme, queries the database for options and menus and widgets on every request, and runs a dozen hooks before rendering anything. The custom application connects to the database once, pulls the post record, and renders a template. There's no overhead to optimize away.
For a content site where performance directly affects SEO (Core Web Vitals, LCP), that's a meaningful difference. Moving from 2.4 seconds to 380ms on the same hardware is not a small improvement — it's the difference between a page that Google considers slow and one it considers fast.