Replacing WordPress with plain PHP

This blog has been running on WordPress for the last couple of years. The software has become more capable and lots of new features were adding during that period. The biggest change was the introduction of the block editor (a.k.a. Gutenberg). This adds more flexibility to content writing by offering options in the form of blocks. And the list of blocks is quickly growing.

But I wasn’t really using any block functionalities. Before, I was usually writing in the ”Text“ view of the editor. This blog is mostly showing code and explaining it. Occasionally adding images, but that is about it. I’m used to writing in plain HTML, because TinyMCE formats text a certain way. And sometimes I need specific formatting of text, or even writing CSS directly in the blog post.

Comments were still a reason to use WordPress, but I never got a lot of comments. And lately I was only receiving spam in the comments section. Antispam Bee was doing a good job of catching spam in a privacy-friendly way. And you can tweak it to your own needs. Removing comments wasn’t going to be a big loss, so I decided they could go.

This website is written in plain PHP with a small, custom framework. Some pages are even in plain HTML. Only the blog part was running WordPress. An advantage of PHP is that you can write HTML directly in PHP files. So a blog post is as simple as creating a PHP file. Some people like to write Markdown, but I quickly run into its limitations and start to write HTML inside Markdown anyway. So I decided to simplify my setup and remove WordPress.

Writing blog posts directly in PHP also has the advantage that I can put the content directly into version control. So no backups are needed, because it is not stored in the database anymore.

Some reasons people mention to move away from WordPress didn’t apply or weren’t valid reasons for me.

  • Maintenance: Not relevant to me, because WordPress was set to auto-update everything. I did this even before this was available for plugins in the admin interface using a hook in PHP: add_filter( 'auto_update_plugin', '__return_true' );. This can also be done for themes: add_filter( 'auto_update_theme', '__return_true' );.
  • Performance: With a proper theme and good plugins WordPress can be fast. Also caching helps. I used Surge, which was the fastest (and simplest) of all caching plugins I tested. And good webhosting is beneficial too.
  • Security: Use trusted themes and plugins that are actively maintained. Add two factor authentication (2FA). I typically use the Two Factor plugin. Choosing a webhoster that has DDoS protection certainly helps. Also WordPress has auto-updates. Use them! And if an update does go wrong, you always have an recent backup, right. RIGHT!?! Some webhosters even offer to do updates for you and automatically test if your site still works as expected afterwards.
  • Search: I wasn’t using the WordPress search anyway, because a lot of my content is not a blog post. For search I use the YaCy search server.

To migrate the blog posts I used the export function in WordPress and wrote a PHP script to generate a file for each post. An export consist of a WXR file written in XML. It has been a while that I had to parse an XML file with namespaces. So it turned out to be a good exercise for me. Below is the PHP script I used. It removes some markup used by WordPress blocks, but I also checked all files for any WordPress specific markup and CSS classes and removed them by hand.


<?php

// Path to WordPress text formating file.
require '/var/www/wordpress/wp-includes/formatting.php';

// Path to WXR export file.
$file = 'WordPress.xml';

// Read XML file.
$doc = new DOMDocument();
$doc->load($file);

// Parse XML.
$items = $doc->getElementsByTagName('item');
foreach ($items as $item) {
  $title = '';
  $content = '';
  $date = '';
  $path = '';
  $isPost = false;
  $publish = false;

  $childNodes = $item->childNodes;
  foreach($childNodes as $childNode) {
    if ($childNode->nodeName === 'title') {
      $title = $childNode->nodeValue;
    }
    if ($childNode->nodeName === 'pubDate') {
      $date = $childNode->nodeValue;
    }
  }

  $wp = $item->getElementsByTagNameNS('http://wordpress.org/export/1.2/', '*');
  foreach ($wp as $wpNode) {
    if ($wpNode->localName === 'status' && $wpNode->nodeValue == 'publish') {
      $publish = true;
    }
    if ($wpNode->localName === 'post_type' && $wpNode->nodeValue == 'post') {
      $isPost = true;
    }
    if ($wpNode->localName === 'post_name') {
      $path = $wpNode->nodeValue;
    }
  }

  if ($isPost && $publish && $title && $path && $date) {
    $timestamp = strtotime($date);
    $contentNodes = $item->getElementsByTagNameNS('http://purl.org/rss/1.0/modules/content/', 'encoded');
    $html = '<main class="post hentry">' .
      "\n" .
      '<h1 class="entry-title p-name">' . $title . "</h1>\n" .
      '<time class="entry-meta dt-published" datetime="' . date('c', $timestamp) . '">' . date('j F Y', $timestamp) . "</time>\n" .
      '<div class="entry-content e-content">' .
      "\n" .
      preg_replace("%<!-- /?wp:[a-z]+(\s{\"id\":\d)? -->(\n|\r)?%um", '', wpautop($contentNodes[0]->nodeValue)) .
      "</div>\n</main>\n";

    // Save HTML to file.
    file_put_contents('/path/to/blogposts/' . $path . '.php', $html);
  }
}

I moved the WordPress uploads folder to a new location, so I had to change all paths of the images. For this I used the find-and-replace function in VS Code.

Some WordPress functionality I had to recreate. This included a RSS feed and a blog page. I did not have that many blog posts, so I decided to list all post in the feed and blog page by parsing all the blog post files. This way the blog page also acts as a sitemap.

So now I’m writing blog posts in plain PHP and happy with it. Static site generators were never an option, because that would mean I need to compile my blog posts. That is the advantage of scripting languages: You don’t need to compile them.