The Code Adventurer: Episode Five – The Database and the Heart of Everything

·

Mechanical heart with data panels and gears in a cavern, viewed by a person holding a torch

Previously, on The Code Adventurer…

Our Adventurer had survived CSS, with its specificity wars and its box model treachery. Our Adventurer had made peace with HTML, learning that structure was not a cage but a gift, and that semantic tags were not just decoration but the skeleton upon which everything else lived. PHP had nearly broken Our Adventurer’s spirit during a particularly dark Tuesday afternoon involving a missing semicolon and eleven minutes of cold sweat. And JavaScript, with its asynchronous chaos and its browser quirks and its Gutenberg block development rabbit hole, had taken three full days of a week that Our Adventurer would never get back.

But something had shifted. The blog, now called The Code Adventurer, had forty-seven published posts. The CSS guide for confused beginners had earned £2,800 in digital product sales. The email list had grown to just over three hundred subscribers, many of whom sent messages saying things like “I finally understand what a div is because of you” and “you explained PHP loops better than my entire university module did.”

Our Adventurer had become, without fully realising it, someone who knew things.

And yet. There was a nagging awareness, the kind that settles in behind your eyes at around eleven in the evening when you have been staring at code for four hours. The awareness that something fundamental was still a mystery. That despite all the CSS wins and the PHP victories and the JavaScript battles, there was an entire layer of WordPress that Our Adventurer had never properly faced.

The database.

The thing that held everything together. The thing that actually remembered what you had written. The invisible vault in which every post, every comment, every setting, every option, every user, every piece of metadata lived in organised rows and columns, quietly keeping the whole operation functional while Our Adventurer fussed about font sizes and hover states.

It was time to go deeper.

It was time to understand the heart of everything.


Chapter One: The First Glimpse into the Vault

It started, as many things did in Our Adventurer’s coding education, with a problem that seemed simple on the surface and turned out to be a trapdoor over a much larger pit.

A reader had asked, in a comment on the PHP episode, whether it was possible to display a list of posts that had been published in the same month one year ago. A kind of “this time last year” section in the sidebar. It sounded like exactly the sort of charming feature that would delight long-term readers, so Our Adventurer immediately opened a new browser tab and typed the question into a search engine.

The first answer involved something called WP_Query.

The second answer involved something called a custom database query using wpdb.

The third answer was from a forum post in 2009 and suggested editing the core WordPress files directly, which Our Adventurer now knew with the certainty of someone who had been burned by this exact mistake was absolutely not the answer.

WP_Query seemed like the right starting point. Our Adventurer had seen it mentioned before, in the PHP episode, lurking at the edges of The Loop. It was time to actually understand what it was.

The WordPress developer documentation described WP_Query as “a class defined in WordPress that deals with the intricacies of a post’s request to a WordPress blog.” Which was technically accurate and almost entirely unhelpful. Our Adventurer translated it mentally as: “the thing you use when you want to ask WordPress for posts, but with specific rules.”

The basic structure looked like this:

$args = array(
'post_type' => 'post',
'posts_per_page' => 5,
'orderby' => 'date',
'order' => 'DESC',
);
$my_query = new WP_Query( $args );
if ( $my_query->have_posts() ) {
while ( $my_query->have_posts() ) {
$my_query->the_post();
the_title();
the_excerpt();
}
wp_reset_postdata();
}

This was already more elegant than Our Adventurer had expected. You defined an array of arguments, told WordPress what you wanted, and WordPress went and fetched it. Like placing a very specific order at a restaurant and having someone else do the kitchen work.

The “this time last year” sidebar widget required a date-based query. Our Adventurer dug into the WP_Query documentation and found the date_query parameter, which turned out to be its own little universe of date-based filtering. You could specify a year, a month, a day, a range, a comparison. You could say “give me posts from exactly one year ago this month” and WordPress would go and find them.

$current_month = date( 'n' );
$last_year = date( 'Y' ) - 1;
$args = array(
'post_type' => 'post',
'posts_per_page' => 5,
'date_query' => array(
array(
'year' => $last_year,
'month' => $current_month,
),
),
);

Our Adventurer stared at this and felt something click. The arguments array was just a way of describing what you wanted. Year equals last year. Month equals this month. The rest was WordPress doing the heavy lifting. The feature went live that afternoon. Within two days, three readers had emailed to say they loved it.

But the experience had opened a door. The WP_Query documentation kept referencing something underneath it, something that WP_Query was itself built on top of. Something called the WordPress database schema.

Our Adventurer clicked the link.

And there it was.


Chapter Two: The Schema, Laid Bare

The WordPress database schema is a map. It is a diagram showing you every table in the database, every column in every table, and how those tables relate to each other. It is the architectural blueprint of the entire WordPress data structure, and looking at it for the first time is either deeply clarifying or deeply overwhelming depending on how much coffee you have had.

Our Adventurer had had two coffees and a biscuit, which turned out to be the correct amount.

The core WordPress database has eleven tables. Our Adventurer printed the schema diagram and put it on the wall next to the desk, which is the sort of thing that happens when you are in deep enough. The tables were:

wp_posts — This is the big one. This table stores posts, pages, custom post types, attachments, navigation menus, revisions, and more. It is far larger in scope than its name suggests. A “post” in WordPress is actually any piece of content. The post_type column is what distinguishes a blog post from a page from a menu item from an uploaded image. Our Adventurer spent a full ten minutes just reading the column names: post_author, post_date, post_content, post_title, post_status, post_name (the slug), post_parent, menu_order, post_type, comment_status. Every post Our Adventurer had ever written was a row in this table. Every page. Every image uploaded through the media library. All of it, sitting in rows, in columns, in a structured vault.

wp_postmeta — This is where extra data about posts lives. The core posts table has a fixed set of columns. If you want to store anything additional about a post (an SEO title, a custom field, a featured image ID, a price for a product), it goes here. Each row has a post_id, a meta_key, and a meta_value. Our Adventurer recognised this immediately. Every time a custom field had been added in the editor, every time the Yoast SEO plugin had stored a focus keyword, every time a post had been given a featured image, WordPress had created a row in this table. The meta table was the flex space of the database, the room where unusual shapes got stored.

wp_terms, wp_term_taxonomy, and wp_term_relationships — These three tables work together to handle categories, tags, and any other taxonomy. A term is a single category or tag. The term taxonomy table clarifies which taxonomy that term belongs to (is it a category, a tag, something custom). The term relationships table links posts to their terms. Our Adventurer traced the relationships with a pencil on the printout and realised that what WordPress calls a “category” is actually assembled from three separate database lookups every time a page loads. The infrastructure under a simple category label was considerably more complex than it appeared.

wp_options — This is where WordPress stores its settings. The site URL, the blog name, the default category, plugin options, widget configurations, theme settings. Essentially: anything that is not a post and not a user goes in here. Each option has a name and a value. Our Adventurer opened phpMyAdmin (a web-based database tool that the local development environment provided) for the first time and looked at the wp_options table directly. There were over eight hundred rows in it. The Yoast SEO plugin alone had contributed dozens. Every plugin that had ever been installed had left its configuration here, like sediment.

wp_users and wp_usermeta — Users and their metadata, following the same post/postmeta pattern. Core user data (login name, email, display name, registration date) in wp_users. Everything extra (role, capabilities, session tokens, plugin-specific user settings) in wp_usermeta.

wp_comments and wp_commentmeta — Comments and their metadata, same structure again.

wp_links — A legacy table from when WordPress had a blogroll feature. Mostly unused in modern installations but still there, patient and quiet, waiting for the blogroll to make a comeback.

Our Adventurer stood back from the wall and looked at the printed schema for a long moment.

Every blog post: wp_posts. Every image: wp_posts with post_type of attachment. Every SEO setting: wp_postmeta. Every category: assembled from three tables. Every plugin option: wp_options. Every user: wp_users. The blog, in its entirety, was a collection of rows and columns, structured relationships between pieces of data, organised so that WordPress could assemble them back into readable pages at request time.

It was beautiful in the way that a very well-organised warehouse is beautiful. Not glamorous, but deeply satisfying.


Chapter Three: WP_Query Goes Deeper

Understanding the database made WP_Query make much more sense. When you wrote a WP_Query with arguments, WordPress was translating those arguments into SQL, the language that databases understand. You did not have to write the SQL yourself. WordPress was writing it on your behalf. But knowing what the database looked like meant you could predict what WordPress was going to ask for, and why.

Our Adventurer decided to build the thing that every WordPress site eventually needs: a featured posts widget for the sidebar. Not just “the five most recent posts,” but something more curated. Posts tagged as “featured” that should appear in the sidebar regardless of when they were published.

Step one was adding a “featured” tag to four existing posts. Step two was the query:

$args = array(
'post_type' => 'post',
'posts_per_page' => 4,
'post_status' => 'publish',
'tag' => 'featured',
'orderby' => 'date',
'order' => 'DESC',
);
$featured = new WP_Query( $args );

It worked on the first attempt, which was suspicious. Our Adventurer spent five minutes looking for what was wrong before accepting that some things in WordPress just work correctly the first time and this is fine.

Emboldened, Our Adventurer tried something more complex: a “related posts” section at the bottom of each post. Posts that shared at least one category with the current post, excluding the current post itself.

$categories = wp_get_post_categories( get_the_ID() );
$args = array(
'post_type' => 'post',
'posts_per_page' => 3,
'post_status' => 'publish',
'post__not_in' => array( get_the_ID() ),
'category__in' => $categories,
'orderby' => 'rand',
);
$related = new WP_Query( $args );

The post__not_in argument was satisfying. You could pass in an array of post IDs to exclude, which meant “give me posts in these categories, but not this specific post I am already reading.” The orderby' => 'rand made it random on each page load, so it felt fresh to repeat visitors.

Our Adventurer deployed it to the live site and within a week, the average session duration in Google Analytics had increased by forty seconds. Related posts, it turned out, made people read more posts. This was both obvious in retrospect and measurably satisfying in the data.

Then came custom post types, which opened another entirely new dimension.


Chapter Four: Custom Post Types and the Reviews Section

A reader had suggested that Our Adventurer should review the coding books and courses that had been used during the learning journey. Not as regular blog posts, but as proper reviews with star ratings and clear metadata. The kind of structured content that should look different from a regular post and be filtered separately.

This was, Our Adventurer quickly discovered, exactly the use case that custom post types were designed for.

A custom post type is WordPress saying: “not everything is a blog post, and not everything should behave like one.” A recipe is not a blog post. A product is not a blog post. A team member profile is not a blog post. A review of a PHP tutorial course is not a blog post. Each of these needs its own archive, its own template, its own metadata structure.

Registering a custom post type in a plugin (because Our Adventurer had learned from the PHP episode never to put this kind of code in functions.php directly) looked like this:

function code_adventurer_register_reviews() {
$labels = array(
'name' => 'Reviews',
'singular_name' => 'Review',
'add_new' => 'Add New Review',
'add_new_item' => 'Add New Review',
'edit_item' => 'Edit Review',
'new_item' => 'New Review',
'view_item' => 'View Review',
'search_items' => 'Search Reviews',
'not_found' => 'No reviews found',
'not_found_in_trash' => 'No reviews found in trash',
);
$args = array(
'labels' => $labels,
'public' => true,
'has_archive' => true,
'rewrite' => array( 'slug' => 'reviews' ),
'supports' => array( 'title', 'editor', 'thumbnail', 'excerpt' ),
'menu_icon' => 'dashicons-star-filled',
'show_in_rest' => true,
);
register_post_type( 'review', $args );
}
add_action( 'init', 'code_adventurer_register_reviews' );

After registering this and flushing the rewrite rules (by visiting Settings > Permalinks and clicking Save, a trick Our Adventurer had learned was necessary after adding new post types), a “Reviews” section appeared in the WordPress admin menu. A new post type, living alongside Posts and Pages, with its own archive at /reviews/.

The custom fields for a review (star rating out of five, course or book title, the author or course creator, the price paid, a one-sentence verdict) were handled using the Advanced Custom Fields plugin, which provides a user interface for adding custom field groups to any post type. Each field in ACF was ultimately stored as a row in wp_postmeta, but the plugin made the data entry experience clean and structured in the admin.

Querying the reviews used the same WP_Query pattern, with 'post_type' => 'review' instead of 'post_type' => 'post'. The custom fields were retrieved using get_field() (the ACF function) or get_post_meta() (the core WordPress function). Our Adventurer wrote a custom template file called single-review.php in the child theme, which WordPress automatically used for single review pages.

The reviews section launched. The first review posted was for a course called “PHP for WordPress Developers,” which had been instrumental in surviving Episode Three. It received fourteen comments in the first week, mostly from readers who had taken the same course and either agreed or violently disagreed with the four-star rating Our Adventurer had given it.

Community, it turned out, emerged from structure.


Chapter Five: The REST API and the World Beyond WordPress

By this point, the blog was a functioning, layered thing. Posts, pages, a reviews CPT, custom fields, a sidebar with featured posts, related posts at the bottom of every article. It was a real website built by someone who understood what they were building.

And then Our Adventurer found the REST API documentation and the world expanded again.

The WordPress REST API is a way of accessing WordPress data without loading a WordPress page. Instead of visiting /blog and getting an HTML page, you could visit /wp-json/wp/v2/posts and get a JSON response containing the same post data in a structured format that any application could consume. Your WordPress site, suddenly, was not just a website. It was a data source. An API. A backend that JavaScript applications, mobile apps, other websites, or custom tools could query.

Our Adventurer’s first REST API experiment was small and practical. The blog had a “now reading” section in the sidebar that showed what book was currently being read, updated manually each time. Our Adventurer wanted to automate this from a reading tracker app. The reading tracker had an API. WordPress had an API. The two could, theoretically, be connected.

The WordPress REST API response for posts looked like this (simplified):

[
{
"id": 247,
"date": "2024-03-15T14:30:00",
"slug": "understanding-wp-query",
"status": "publish",
"title": {
"rendered": "Understanding WP_Query: A Beginner's Complete Guide"
},
"content": {
"rendered": "<p>When I first encountered WP_Query...</p>"
},
"excerpt": {
"rendered": "<p>A complete guide to querying WordPress posts...</p>"
},
"author": 1,
"featured_media": 892,
"categories": [3, 7],
"tags": [12, 18, 24]
}
]

JSON. Clean, structured, readable. Every post, every page, every custom post type (if registered with 'show_in_rest' => true, which Our Adventurer had done) was available through the API.

The custom endpoint experiment for the “now reading” widget involved registering a custom REST API route that could receive a POST request from the reading app and update an option in wp_options:

add_action( 'rest_api_init', function() {
register_rest_route( 'code-adventurer/v1', '/now-reading', array(
'methods' => 'POST',
'callback' => 'update_now_reading',
'permission_callback' => function( $request ) {
return current_user_can( 'edit_posts' );
},
) );
} );
function update_now_reading( $request ) {
$book_title = sanitize_text_field( $request->get_param( 'title' ) );
$book_author = sanitize_text_field( $request->get_param( 'author' ) );
update_option( 'now_reading_title', $book_title );
update_option( 'now_reading_author', $book_author );
return new WP_REST_Response( array( 'success' => true ), 200 );
}

Two things stood out in this code that Our Adventurer had learned the hard way. First, the permission_callback was not optional for POST requests. An early version of this endpoint had used 'permission_callback' => '__return_true' (which lets anyone call it without authentication), and a security scanner had flagged this immediately. Lesson learned. Second, the data coming in was sanitised using sanitize_text_field() before being stored. You never trust incoming data. This was a principle that extended far beyond REST API development but was particularly important there.

The “now reading” widget was now updated automatically each time a book was finished in the reading tracker. It was, objectively, a feature that approximately zero readers would notice or care about. But it had required learning custom REST API endpoints, permission callbacks, and option storage, which meant it had been completely worth building.


Chapter Six: Performance, or Why Speed is Not an Afterthought

The blog had grown. Forty-seven posts. A custom post type. Related posts queries. A featured posts widget. A REST API endpoint. Traffic had grown too: somewhere between six hundred and nine hundred unique visitors per day, on a typical weekday.

And then Our Adventurer ran the site through Google PageSpeed Insights for the first time.

The mobile score was 54 out of 100.

Fifty-four.

Our Adventurer made a cup of tea and sat with this for a moment. Fifty-four meant “needs improvement,” which was the PageSpeed way of saying “your site is not fast enough and your users are quietly suffering.” The desktop score was 71, which was marginally better but still not good.

The recommendations listed were numerous. Serve images in next-gen formats. Eliminate render-blocking resources. Reduce unused JavaScript. Properly size images. Enable text compression. Leverage browser caching.

Each recommendation was a small course in itself. Our Adventurer started at the top.

Image optimisation turned out to be the single biggest win. Every image on the site had been uploaded at the size it was exported from a phone or screenshot tool, which meant some were three and four megabytes. WordPress automatically generates multiple sizes when you upload an image (thumbnail, medium, large, and full), but it does not automatically compress them or convert them to WebP format.

The solution came in two parts. First, a plugin called Imagify (there were several options, this was the one Our Adventurer chose) that automatically compressed images on upload and converted them to WebP where supported. Second, going back through old posts and regenerating thumbnails for all existing images using a plugin called Regenerate Thumbnails. This was a one-time bulk operation that took about twenty minutes to run on forty-seven posts worth of images, during which Our Adventurer watched the progress bar and thought about the nature of patience.

After the image work, the mobile score rose to 67. Progress.

Caching was the next major item. Every time someone visited the blog, PHP ran. WordPress initialised, connected to the database, ran queries, assembled the page from templates and data, and returned HTML. This happened on every request, for every visitor, every time. For a low-traffic blog this was fine. For a site receiving six hundred visitors a day, it meant the server was doing considerable work that it did not need to do, because the result of most of those requests was the same: the same post, assembled the same way, serving the same HTML.

Caching solved this by saving the assembled HTML and serving that directly on subsequent requests, skipping the PHP and database work entirely. Our Adventurer installed WP Rocket (a premium caching plugin, worth every pound of its annual fee based on subsequent results) and followed the recommended configuration. Page caching on. Browser caching on. CSS and JavaScript minification on. GZIP compression on.

The mobile score went to 81.

From 54 to 81 in a single afternoon of focused work. Our Adventurer sat back and felt the specific satisfaction of a problem genuinely solved.

There was more to learn about performance, there always was: lazy loading, critical CSS, DNS prefetching, CDN configuration. But the principle was established. Performance was not something you bolt on at the end. It was something you built toward, incrementally, with each decision. And when you neglected it, the PageSpeed score would sit there at 54 and remind you.

A CDN (Content Delivery Network) came next. The blog was hosted on a server in a data centre. That server was in one physical location. A visitor in London got content quickly because the server was nearby. A visitor in Melbourne got content more slowly because the data had to travel significantly further. A CDN solved this by distributing copies of the site’s static assets (images, CSS, JavaScript) to servers in multiple locations around the world, so that a visitor in Melbourne got their images from a server in Sydney rather than London.

Our Adventurer had been using Cloudflare on the free tier since the early days of the blog (largely because a Stack Overflow answer had said “you should use Cloudflare” and Our Adventurer had followed the advice without fully understanding why). Now, for the first time, Our Adventurer understood what it was actually doing. The DNS was pointed at Cloudflare. Cloudflare sat between the visitor and the origin server. Static assets were cached at the edge. Dynamic requests passed through to the origin. The full picture finally made sense.

Mobile PageSpeed score: 86.

Our Adventurer updated the post drafts folder with a note: “Write a post about performance. People need to know this earlier than I did.”


Chapter Seven: Security, or What Happens When You Are Not Paying Attention

It happened on a Wednesday.

Our Adventurer checked the security plugin dashboard (Wordfence, installed several months earlier on the advice of the PHP episode research) and found a notification that had arrived at 3:47 AM. An automated login attack had been detected and blocked. Over four hundred login attempts in eleven minutes, all targeting the default WordPress login URL with a list of common username and password combinations.

None of them had succeeded. The Wordfence firewall had blocked the IP after the fifth failed attempt. The attack had run into a wall and given up, as automated attacks do when they encounter resistance.

But the notification was a reminder. The internet was full of automated bots scanning for vulnerable WordPress installations. They ran continuously, probing for weak passwords, outdated plugins, exposed admin URLs, and known vulnerabilities. Most of them were unsophisticated and easily blocked. But you had to have the blocks in place.

Our Adventurer spent an afternoon reviewing and tightening the security posture of the site. The checklist that emerged became a future blog post:

Keep everything updated. WordPress core, themes, and plugins. Updates frequently contain security patches for known vulnerabilities. Running an outdated plugin with a known exploit was an open invitation. Our Adventurer had been generally good about this but formalised it: every Monday, check for updates and apply them. No exceptions.

Use strong, unique passwords. The admin account password was updated to a randomly generated sixty-four-character string stored in a password manager. Not memorable. Completely unguessable. That was the point.

Two-factor authentication. Added to the admin account using the Wordfence 2FA feature. Now, in addition to the password, logging in required a time-based one-time code from an authenticator app. An attacker with the correct password was still locked out without the second factor. This was a five-minute setup that added significant protection.

Change the login URL. The default WordPress login URL is /wp-login.php. Every automated bot knew this. Moving the login to a custom URL (using a plugin called WPS Hide Login) meant that bots scanning for /wp-login.php would find nothing. It was security through obscurity, which is not a complete strategy on its own, but it dramatically reduced the noise of automated login attempts.

Limit login attempts. Wordfence handled this. After a configurable number of failed login attempts from a single IP, that IP was temporarily locked out. This broke brute-force attacks that worked by trying thousands of password combinations.

HTTPS everywhere. The site had been running on HTTPS since launch, courtesy of a free SSL certificate from Let’s Encrypt via the hosting provider. But Our Adventurer double-checked that all URLs in the database were HTTPS and that the site was redirecting HTTP to HTTPS. The Really Simple SSL plugin confirmed everything was in order.

Principle of least privilege. The site had one admin account and one editor account. The editor account was used for day-to-day writing and publishing. The admin account was only used when administrative tasks (plugin installation, settings changes) were required. This meant that if a session were somehow compromised during normal use, the attacker would have editor permissions, not admin permissions.

Backups. UpdraftPlus was already configured and running daily backups to a connected Google Drive folder. The database was backed up daily. Files were backed up weekly. Backups were tested by doing a test restore to the local development environment once a month. Many people set up backups and never verify they actually work. Our Adventurer had learned from a YouTube video about a site owner who had discovered their backup had been failing silently for six months, exactly when they needed it. Testing the backup was not paranoia. It was due diligence.

After the security afternoon, Our Adventurer wrote a note and pinned it above the desk. It said:

“Security is not a feature you add. It is a posture you maintain.”

The Wednesday login attack notification sat in the Wordfence log. Four hundred and thirty-three attempts. Zero successes. The wall had held.


Chapter Eight: Direct Database Queries and When They Are Necessary

There came a day when WP_Query was not enough.

The blog had a page that listed all the books mentioned across all posts, assembled from a custom field where each post could list up to three books referenced in the article. The goal was to create a kind of bibliography: every book that had ever been mentioned, without duplicates, sorted alphabetically by title.

WP_Query could retrieve the posts. But WP_Query could not efficiently retrieve distinct values from a custom field across all posts and deduplicate them. That required SQL. That required going to the database directly.

WordPress provided a way to do this safely through a global called $wpdb. This was the WordPress database class, and it provided methods for running raw SQL queries while still going through WordPress’s database connection and, critically, while still allowing proper data sanitisation.

global $wpdb;
$books = $wpdb->get_results(
$wpdb->prepare(
"SELECT DISTINCT meta_value
FROM {$wpdb->postmeta}
WHERE meta_key = %s
AND meta_value != ''
ORDER BY meta_value ASC",
'book_reference'
)
);

Two things in this code were non-negotiable. First, using $wpdb->prepare() instead of building the SQL string directly. The prepare method used placeholders (%s for strings, %d for integers) and handled the escaping of values, preventing SQL injection attacks. Writing raw SQL with unescaped user input (or even plugin option values) was one of the most common and serious WordPress security vulnerabilities. The prepare method was not optional. Second, using {$wpdb->postmeta} instead of hardcoding wp_postmeta. The table prefix could be changed during WordPress installation. Using the $wpdb property meant the code would work regardless of what prefix had been chosen.

The books page went live. It listed forty-seven books across all forty-seven posts, sorted alphabetically, with each book linked to the post that had mentioned it. It was the kind of page that a dedicated reader would find and spend twenty minutes with. Several dedicated readers did exactly that.

The direct database query felt like a line had been crossed in a good way. Our Adventurer had gone past the abstraction layer and touched the actual data structure. The safety rails were still on (prepare(), the $wpdb properties, proper escaping) but the direct access was there.

WordPress was not a black box anymore. It was a known system, with known components, and Our Adventurer understood most of them.


Chapter Nine: The Forty-Eighth Post and the Decision

It was a Saturday afternoon when Our Adventurer opened a new document and typed the title:

“Everything I Have Learned About WordPress in One Year”

And then sat there for a long time.

Forty-seven posts. A custom post type. Related posts. Featured posts. REST API endpoints. Security hardening. Performance optimisation. Database queries. WP_Query with its arguments array and its loops. PHP with its hooks and its filters and its one terrible morning involving a missing semicolon. JavaScript with its asynchronous surprises and its three-day Gutenberg block. HTML with its semantic structure and its accessibility principles. CSS with its specificity and its box model and its Flexbox wonder.

A year ago, Our Adventurer had watched a YouTube video about a friend who had earned eleven thousand pounds selling ceramic mugs through a WordPress site, and had thought: I should try that.

The digital product was now generating around four hundred pounds a month in passive income. The email list had grown to three hundred and twelve subscribers. The blog had been mentioned in two other blogs’ “resources” pages. A podcast had reached out about a potential interview episode.

But none of that was the point, Our Adventurer thought. The point was that understanding had compounded. Every thing learned had made the next thing easier to learn. CSS specificity had made PHP’s variable scoping make more sense. PHP’s hooks and filters had made JavaScript’s event system feel familiar. The database schema had made WP_Query feel obvious. Each layer of knowledge sat on top of the previous one.

The blank document sat open. The cursor blinked.

It was time to write the forty-eighth post. The one that pulled it all together.

But first, there was one more chapter of the journey to live through.

The climactic battle was still ahead.


Next episode: The Climactic Battle, the Blank Page, and the First Great Post. Our Adventurer faces the final boss, the one that all the CSS, HTML, PHP, JavaScript, and database knowledge was preparing for. The boss was not a language. The boss was not a framework. The boss was something far more familiar, and far more terrifying.

The boss was Our Adventurer.

Leave a Reply

Get updates

From art exploration to the latest archeological findings, all here in our weekly newsletter.

Subscribe

Discover more from Richard Morrison

Subscribe now to keep reading and get access to the full archive.

Continue reading