Every lesson follows the same pattern: understand it โ see it working โ build it yourself.
Logged in as: Guest
PHP is the language. WordPress is a tool built on top of it.
PHP gives you the basics: print text, do maths, loop through a list. WordPress wraps those basics with functions that know about your site โ your posts, your users, your settings.
A simple way to think about it: PHP is like a kitchen. WordPress is the recipe book written for that kitchen. You still need the kitchen, but the recipe book saves you from starting from scratch every time.
This code is running right now. The output below is real โ pulled live from your site.
<?php
// Pure PHP โ just maths
$php_result = 10 + 5;
// WordPress โ asks the database
$site_name = get_bloginfo( 'name' );
$wp_version = get_bloginfo( 'version' );
$user_id = get_current_user_id();
echo "PHP maths result: " . $php_result . "\n";
echo "Site name (WP): " . $site_name . "\n";
echo "WP version (WP): " . $wp_version . "\n";
echo "Current user ID: " . $user_id;
?>
"WordPress functions are wrappers around PHP that handle things like database queries, caching, and security for you. Using get_bloginfo() instead of a raw SQL query means WordPress can cache the result and you don't risk injection bugs."
A variable is just a named box that holds a value. In PHP every variable starts with a dollar sign $.
You use them to store information so you can reuse it without repeating yourself. Think of them like sticky labels on boxes โ the label is the variable name, the contents of the box is the value.
<?php
$role = "WordPress Engineer";
$company = "Human Made";
$skills = ["PHP", "WordPress", "REST API", "Git"];
$has_job = false;
// Joining strings together is called "concatenation"
echo $role . " at " . $company . "\n";
// Loop through an array
foreach ( $skills as $skill ) {
echo "- " . $skill . "\n";
}
// Check a boolean
if ( ! $has_job ) {
echo "\nKeep going โ interview prep in progress.";
}
?>
WordPress runs through a sequence of steps every time a page loads โ like a checklist. At certain points in that checklist it shouts: "Anyone want to do something here?"
Those shouting points are action hooks. You use add_action() to say "yes, run my function at that point."
Real-world analogy: Imagine a school timetable. WordPress is the timetable, and wp_footer is the "end of day" slot. You add an action to put your stuff in that slot.
add_action( 'hook_name', 'your_function_name', priority, args );
// Example: runs when WordPress loads the <head>
add_action( 'wp_head', 'my_custom_tracking_code' );
function my_custom_tracking_code() {
echo '<!-- tracking snippet here -->';
}
The priority (default: 10) decides order. Lower numbers run first.
This hook fired right now when WordPress built this page. We captured the timestamp to prove it.
<?php
// This would go in functions.php in real code.
// Here we simulate it by calling do_action() manually.
$log = [];
add_action( 'demo_page_start', function() use ( &$log ) {
$log[] = "[Priority 10] First function ran at: " . date('H:i:s');
});
add_action( 'demo_page_start', function() use ( &$log ) {
$log[] = "[Priority 20] Second function ran at: " . date('H:i:s');
}, 20 );
do_action( 'demo_page_start' );
?>
add_action( 'wp_footer', 'zix_my_first_footer_note' );
function zix_my_first_footer_note() {
echo '<p style="text-align:center;color:#999;">Built by Busayo</p>';
}
"Action hooks are how WordPress lets you run code at specific points in the page lifecycle without modifying core files. This keeps upgrades safe โ your plugin or theme code stays separate from WordPress itself."
Actions say "do something at this point." Filters say "take this value, change it, and give it back."
A filter always receives a value, and you must always return a value from it. If you forget to return something, you'll break the page.
Analogy: A filter is like a coffee machine. Raw water goes in, filtered water comes out. You don't throw the water away โ you change it.
add_filter( 'the_title', 'my_title_modifier' );
function my_title_modifier( $title ) {
return 'โญ ' . $title; // always return the value
}
<?php
// apply_filters() sends a value through any registered filter functions.
$raw_title = "WordPress Engineering at Human Made";
// This filter adds a prefix
add_filter( 'demo_title_filter', function( $title ) {
return strtoupper( $title );
});
// This filter adds a suffix (runs after, priority 20)
add_filter( 'demo_title_filter', function( $title ) {
return $title . ' โ';
}, 20 );
$final_title = apply_filters( 'demo_title_filter', $raw_title );
echo "Before: " . $raw_title . "\n";
echo "After: " . $final_title;
?>
WordPress stores extra information about each user in a table called wp_usermeta. Each row has a user ID, a key, and a value.
Think of it like a filing cabinet โ each user has their own drawer, and inside are labelled folders (keys) with documents inside (values).
Always prefix your keys to avoid clashing with other plugins โ e.g. zix_learning_goal not just learning_goal.
This reads your stored note from the database. If nothing is saved yet it shows a default message.
<?php
$uid = get_current_user_id();
$saved_note = get_user_meta( $uid, 'zix_journal_note', true );
echo "User ID: " . $uid . "\n";
echo "My note: " . $saved_note;
?>
After saving, scroll back to the "See it working" section โ your note will appear there.
"I always use sanitize_text_field() before saving user input and wp_nonce_field() on every form. The nonce verifies the form was submitted from my site, not forged from somewhere else."
WordPress runs inside one PHP environment. Every plugin and theme is loaded together. That means if two plugins both define a function called get_user_name(), PHP throws a fatal error.
The fix: always prefix your function names, class names, hooks, and meta keys with something unique โ usually a short project code.
A WordPress plugin at minimum needs one PHP file with a specific comment block at the top (the "plugin header").
Here is the minimum structure of a real plugin file:
<?php
/*
* Plugin Name: My Custom Plugin
* Description: Does something useful.
* Version: 1.0.0
* Author: Busayo Felix
*/
// Always guard against direct file access
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// Prefixed function โ won't clash with other plugins
function zix_hello_world() {
return "Hello from my plugin!";
}
// Hooked in cleanly
add_action( 'wp_footer', 'zix_hello_world' );
?>
<?php
// Detecting name clashes โ PHP will tell you:
// Fatal error: Cannot redeclare my_function()
// Safe version with prefix check:
if ( ! function_exists( 'zix_safe_example' ) ) {
function zix_safe_example() {
return "Only defined once, safely.";
}
}
echo zix_safe_example();
?>
function zix_show_login_status() {
if ( is_user_logged_in() ) {
$user = wp_get_current_user();
echo '<p style="text-align:center">Logged in: ' . esc_html( $user->display_name ) . '</p>';
}
}
add_action( 'wp_footer', 'zix_show_login_status' );
"I always prefix everything โ functions, classes, meta keys, hooks โ with a project-specific code. At Human Made scale, you have dozens of plugins loaded at once. A naming collision causes a fatal error on a live production site."
Classic WordPress themes use PHP templates (header.php, single.php) to control layout. Block themes replace those with HTML files that contain block markup โ and almost all design decisions live in a single file: theme.json.
Why does this matter? Human Made builds enterprise WordPress platforms. Block themes mean editors can change layouts in the Site Editor without touching code. Your job is to control what they can and can't change through theme.json.
Key things theme.json controls:
This is a real theme.json example. Copy this pattern โ this is exactly what you'd write at Human Made.
{
"$schema": "https://schemas.wp.org/trunk/theme.json",
"version": 2,
"settings": {
"color": {
"defaultPalette": false,
"palette": [
{ "name": "Brand Dark", "slug": "brand-dark", "color": "#0f0c29" },
{ "name": "Brand Mid", "slug": "brand-mid", "color": "#302b63" },
{ "name": "White", "slug": "white", "color": "#ffffff" }
]
},
"typography": {
"fontFamilies": [
{
"name": "Inter",
"slug": "inter",
"fontFamily": "Inter, sans-serif"
}
],
"fontSizes": [
{ "name": "Small", "slug": "small", "size": "14px" },
{ "name": "Normal", "slug": "normal", "size": "16px" },
{ "name": "Large", "slug": "large", "size": "24px" }
]
},
"layout": {
"contentSize": "960px",
"wideSize": "1200px"
}
},
"styles": {
"color": {
"background": "var(--wp--preset--color--white)",
"text": "var(--wp--preset--color--brand-dark)"
},
"typography": {
"fontFamily": "var(--wp--preset--font-family--inter)",
"fontSize": "var(--wp--preset--font-size--normal)"
}
}
}
"In block themes, theme.json is where I enforce design constraints. Setting defaultPalette: false means editors can only pick from the approved brand colours โ they can't introduce random colours that break the design system. That's important at enterprise scale."
There are two moments where you must be careful with user input:
And there's a third thing: Nonces (number used once). These are security tokens added to forms and AJAX requests to prove the request is genuine โ not forged by a malicious site.
An attacker might try to put JavaScript inside a form field. Watch what the escape functions do to it:
<?php
$attacker_input = '<script>alert("hacked!")</script>';
// Sanitize before saving โ strips the whole script tag
$sanitized = sanitize_text_field( $attacker_input );
// Escape before displaying โ converts < and > to HTML entities
// so the browser shows the text instead of running the script
$safe_to_show = esc_html( $attacker_input );
echo "After sanitize_text_field(): " . $sanitized . "\n";
echo "After esc_html(): " . $safe_to_show;
?>
"I treat input and output as two separate security steps. Sanitize on the way in, escape on the way out. I never trust that something was sanitized when it was saved โ I always escape at the point of display. esc_html() for text, esc_attr() for attributes, esc_url() for URLs."
All your WordPress content โ posts, users, settings โ lives in a MySQL database. PHP talks to that database, and WordPress gives you a global variable called $wpdb to do it safely.
The most important rule: never put user input directly into a SQL query. If you do, an attacker can manipulate the query and read or delete your database. This is called SQL Injection.
The fix is $wpdb->prepare() โ it uses placeholders (%s for strings, %d for integers) and fills them in safely.
<?php
global $wpdb;
// โ NEVER do this โ SQL injection risk
$results = $wpdb->get_results( "SELECT * FROM wp_users WHERE user_login = '" . $login . "'" );
// โ
ALWAYS do this โ $wpdb->prepare() makes it safe
$results = $wpdb->get_results(
$wpdb->prepare( "SELECT * FROM %i WHERE user_login = %s", $wpdb->users, $login )
);
?>
This queries the database right now and shows you real meta data from your account.
<?php
global $wpdb;
$uid = get_current_user_id();
// Fetch all meta keys that start with 'zix_' for the current user
$meta_rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT meta_key, meta_value
FROM {$wpdb->usermeta}
WHERE user_id = %d
AND meta_key LIKE %s
LIMIT 5",
$uid,
'zix_%'
)
);
foreach ( $meta_rows as $row ) {
echo $row->meta_key . ': ' . $row->meta_value . "\n";
}
?>
$count = $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$wpdb->usermeta} WHERE user_id = %d",
$uid
)
);
echo "Total meta rows: " . $count;
"I always use $wpdb->prepare() for any query that includes dynamic values. The prepare method uses parameterized queries โ it separates the SQL structure from the data, which is the correct way to prevent SQL injection at the database level."
The WordPress REST API lets external apps (or your own JavaScript) talk to WordPress over HTTP. Instead of loading a full PHP page, you make a request to a URL like /wp-json/wp/v2/posts and get back clean JSON data.
Why it matters at Human Made: Enterprise platforms often need to push data between systems โ a React front-end, a mobile app, a third-party CMS. The REST API is how WordPress speaks to all of them.
You can also register your own endpoints:
add_action( 'rest_api_init', function() {
register_rest_route( 'zix/v1', '/hello', [
'methods' => 'GET',
'callback' => 'zix_hello_endpoint',
'permission_callback' => '__return_true', // public
]);
});
function zix_hello_endpoint( WP_REST_Request $request ) {
return new WP_REST_Response([
'message' => 'Hello from my custom endpoint!'
], 200);
}
Your endpoint would then be live at: /wp-json/zix/v1/hello
Let's call the built-in WordPress REST API right now using PHP's wp_remote_get(). This fetches the 3 most recent posts from your own site.
<?php
// Calling your own REST API from PHP
$api_url = home_url( '/wp-json/wp/v2/posts?per_page=3&_fields=id,title,link' );
$response = wp_remote_get( $api_url );
if ( ! is_wp_error( $response ) ) {
$posts = json_decode( wp_remote_retrieve_body( $response ), true );
foreach ( $posts as $post ) {
echo $post['id'] . ' โ ' . $post['title']['rendered'] . "\n";
}
}
?>
add_action( 'rest_api_init', function() {
register_rest_route( 'zix/v1', '/me', [
'methods' => 'GET',
'callback' => 'zix_me_endpoint',
'permission_callback' => 'is_user_logged_in',
]);
});
function zix_me_endpoint() {
$user = wp_get_current_user();
return new WP_REST_Response([
'name' => $user->display_name,
'email' => $user->user_email,
], 200);
}
Then visit https://kistulab.com/wp-json/zix/v1/me in your browser while logged in.
"The REST API is how I decouple the WordPress back-end from the front-end. I register custom namespaced endpoints โ vendor/v1/ โ with proper permission callbacks so public routes return only safe data and authenticated routes are protected. I always use WP_REST_Response so WordPress handles the headers correctly."
Git is version control software. It tracks every change you make to your code, lets you go back to any previous version, and lets teams work on the same project without breaking each other's work.
A branch is like a copy of the codebase you can work on freely. When you're done, you merge it back into the main branch through a Pull Request (PR) โ which gives teammates a chance to review your code before it goes live.
At Human Made, branching strategy matters. The typical flow is:
Here is the exact workflow used on a typical Human Made project:
# 1. Always start from the latest main
git checkout main
git pull origin main
# 2. Create your feature branch
git checkout -b feature/zix-rest-api-endpoint
# 3. Do your work, then stage and commit
git add .
git commit -m "feat: add /zix/v1/me REST endpoint"
# 4. Push your branch to GitHub/GitLab
git push origin feature/zix-rest-api-endpoint
# 5. Open a Pull Request on GitHub
# Request a review from a teammate
# 6. After review + approval, merge into main
# Then delete the feature branch โ it's done
# Good commit message format (Conventional Commits)
feat: add custom REST endpoint for user profile
fix: correct nonce check in form handler
docs: update README with endpoint list
refactor: extract user query to helper function
cd /Users/busayofelix/Desktop/zix-child
git status
git log --oneline -5
git checkout -b feature/learning-journal-updates
git add .
git commit -m "feat: update learning journal with 3-part lesson structure"
git push origin feature/learning-journal-updates
"I follow a feature branch workflow โ one branch per task, always branched from the latest main. I write commit messages in the conventional commits format so the changelog is readable. I never push directly to main. Every change goes through a PR, even small ones, because code review is how the team catches bugs before they hit production."
Modern WordPress projects use two package managers:
Think of them both like an App Store for code. Instead of copy-pasting someone else's library into your project, you list what you need, and the package manager downloads and manages it for you.
At Human Made, you'll also use npm scripts to run tasks: building CSS, linting code, running tests.
Here are the files you'd find in a real Human Made WordPress project:
// package.json โ JavaScript dependencies and scripts
{
"name": "my-hm-theme",
"scripts": {
"build": "webpack --mode production",
"start": "webpack --mode development --watch",
"lint": "eslint assets/js/**/*.js",
"lint:css": "stylelint assets/css/**/*.scss"
},
"devDependencies": {
"@wordpress/scripts": "^27.0.0",
"eslint": "^8.0.0"
}
}
// composer.json โ PHP dependencies
{
"name": "human-made/my-project",
"require": {
"php": ">=8.1"
},
"require-dev": {
"squizlabs/php_codesniffer": "^3.7",
"wp-coding-standards/wpcs": "^3.0",
"phpunit/phpunit": "^9.0"
},
"scripts": {
"lint": "phpcs --standard=WordPress .",
"test": "phpunit"
}
}
cd /Users/busayofelix/Desktop/zix-child
cat package.json
"I'm comfortable with both npm and Composer. On the PHP side I use PHPCS with the WordPress Coding Standards ruleset to catch style issues automatically. On the JS side I use @wordpress/scripts which gives you a webpack setup pre-configured for Gutenberg block development. Running these as CI checks means code quality problems are caught before review."
Accessibility means your site works for everyone โ including people using screen readers, keyboard-only navigation, or who have low vision.
Governments and enterprise clients often require WCAG 2.1 AA compliance. Human Made builds for enterprise organisations, so accessibility is a real requirement โ not an optional extra.
The most common issues (and how to fix them):
Bad vs good โ look at the HTML source difference. Tab through these with your keyboard to feel the difference.
<!-- โ BAD โ not keyboard accessible, no semantics -->
<div onclick="doSomething()" style="cursor:pointer;">Click me</div>
<!-- โ
GOOD โ button gets focus, Enter key works, screen reader says "button" -->
<button type="button" onclick="doSomething()">Click me</button>
<!-- โ BAD form โ screen reader doesn't know what this field is -->
<input type="text" placeholder="Enter email">
<!-- โ
GOOD form โ label linked by 'for' and 'id' attributes -->
<label for="user-email">Email address</label>
<input type="email" id="user-email" name="email">
<!-- Skip link โ lets keyboard users jump past the nav -->
<a href="#main-content" class="skip-link">Skip to content</a>
Tab through these โ notice which ones get a visible focus ring:
"I follow WCAG 2.1 AA as a baseline. The practical things I check every time: semantic HTML (buttons for actions, links for navigation), visible focus states, colour contrast ratios, and labels on every form field. I use Lighthouse and keyboard-only testing before any PR is raised."
Debugging is the process of finding out why code isn't doing what you expect. In WordPress, there are two main tools:
Add these three lines to wp-config.php to turn on debug mode:
define( 'WP_DEBUG', true ); // Show PHP errors
define( 'WP_DEBUG_LOG', true ); // Write errors to /wp-content/debug.log
define( 'WP_DEBUG_DISPLAY', false ); // Don't show errors to site visitors
This code writes to your debug log right now (if WP_DEBUG_LOG is on) and shows you diagnostic output on screen.
<?php
// Writing to the debug log โ check /wp-content/debug.log after running this
error_log( '[MyPlugin] User ' . get_current_user_id() . ' loaded the page at ' . current_time('mysql') );
// Printing a variable's contents for inspection (use this during development only)
$my_data = [ 'post_id' => 42, 'status' => 'published' ];
error_log( '[MyPlugin] Data: ' . print_r( $my_data, true ) );
// wp_die() stops execution and shows a message โ useful during debugging
// wp_die( 'Stopping here to inspect' );
?>
"My debugging workflow: first I check WP_DEBUG_LOG โ errors are there without showing anything to users. Then I use Query Monitor to find slow queries or unexpected hook calls. I use error_log( print_r( $var, true ) ) to inspect variables during development, and I always remove those before committing. For deeper issues I step through code with Xdebug."