WordPress: Beyond the Basics
A reference for experienced users who know their way around the dashboard and want to go deeper.
Contents
- Performance & Caching
- wp-config.php
- WP-CLI
- Hook System
- Custom Post Types & Taxonomies
- Block Editor (Gutenberg)
- REST API
- Multisite
- Security Hardening
- Deployment & Version Control
- Child Themes vs. Plugins
- Useful Patterns & Snippets
- Debugging Toolkit
01 — Performance & Caching
How WordPress Caching Actually Works
WordPress generates pages dynamically on every request by default — PHP executes, queries run against MySQL, and HTML is assembled. Caching short-circuits this at various layers:
- Object cache — stores database query results in memory (Redis or Memcached). Without a persistent object cache, WordPress's built-in object cache only lasts for a single page request.
- Page cache — stores fully-rendered HTML. Plugins like WP Rocket, W3 Total Cache, or LiteSpeed Cache handle this; so do some hosts at the server level (Kinsta, WP Engine, Cloudways).
- Opcode cache — PHP compiles your
.phpfiles to bytecode and caches that in memory (OPcache). Most hosts enable this by default; verify with aphpinfo()page. - CDN / edge cache — Cloudflare, Bunny.net, or your host's CDN caches assets and sometimes full pages at the edge.
The most impactful upgrade for a busy site is adding a persistent Redis object cache. Install the Redis Object Cache plugin, point it at your Redis instance, and watch admin-panel queries drop dramatically.
Database Housekeeping
WordPress accumulates dead weight over time. Run these with WP-CLI or a plugin like WP-Optimize:
# Delete post revisions older than 30 days
wp post delete $(wp post list --post_type=revision --format=ids) --force
# Clear transients
wp transient delete --expired
# Optimize tables
wp db optimize
Limit revisions globally by adding this to wp-config.php:
define( 'WP_POST_REVISIONS', 5 );
Query Performance
Install Query Monitor (free, essential) to see exactly which queries are running on each page load, how long they take, and which plugin or theme triggered them. A slow query or an N+1 problem is often the real culprit behind a slow admin or slow front end — not theme bloat.
02 — wp-config.php: The Levers Most People Ignore
wp-config.php is the most powerful configuration file in a WordPress install. Beyond database credentials, it controls:
// Increase memory limit (check your host's hard ceiling first)
define( 'WP_MEMORY_LIMIT', '256M' );
define( 'WP_MAX_MEMORY_LIMIT', '512M' ); // for admin
// Disable the file editor in the dashboard (security best practice)
define( 'DISALLOW_FILE_EDIT', true );
// Disable all plugin/theme updates and installs (lock down production)
define( 'DISALLOW_FILE_MODS', true );
// Force SSL for admin and logins
define( 'FORCE_SSL_ADMIN', true );
// Enable debug logging to a file instead of the screen
define( 'WP_DEBUG', true );
define( 'WP_DEBUG_LOG', true ); // logs to /wp-content/debug.log
define( 'WP_DEBUG_DISPLAY', false );
// Move wp-content outside the webroot (advanced)
define( 'WP_CONTENT_DIR', '/home/user/wp-content' );
define( 'WP_CONTENT_URL', 'https://example.com/wp-content' );
// Set a custom table prefix (should be done at install, but can be migrated)
$table_prefix = 'xk7_';
// Salts and keys — regenerate at https://api.wordpress.org/secret-key/1.1/salt/
// Rotate these when you suspect a breach; all logged-in users are signed out
03 — WP-CLI: The Right Way to Manage WordPress at Scale
If you're not using WP-CLI, you're working harder than you need to. It handles almost everything the dashboard does, plus things it can't:
# Update core, all plugins, all themes in one pass
wp core update && wp plugin update --all && wp theme update --all
# Search-replace across the entire database (multisite-safe)
wp search-replace 'http://old-domain.com' 'https://new-domain.com' --precise
# Export/import content
wp export --dir=/tmp/exports
wp import /tmp/exports/file.xml --authors=create
# Create an admin user
wp user create jane jane@example.com --role=administrator --user_pass=SecurePass1
# Run a custom PHP snippet against the live site
wp eval 'echo get_option("blogname");'
# Profile a slow hook
wp profile hook --all --order-by=query_time
# Generate dummy content for testing
wp post generate --count=50 --post_type=product
WP-CLI also accepts --ssh=user@host to run commands on remote servers without logging in first, making it ideal for deployment scripts and CI pipelines.
04 — The WordPress Hook System
Understanding actions and filters is the difference between hacking core files (bad) and writing maintainable code.
Actions let you run code at a specific point in execution. Filters let you intercept and modify data before it's used.
// Action: run code when a post is published
add_action( 'publish_post', function( $post_id ) {
// Ping an external webhook, update a cache, etc.
}, 10, 1 ); // priority 10, 1 argument
// Filter: modify the_content before output
add_filter( 'the_content', function( $content ) {
if ( is_single() ) {
$content .= '<p>Thanks for reading.</p>';
}
return $content; // always return from a filter
} );
// Remove someone else's hook (you need the same priority and callback reference)
remove_action( 'wp_head', 'wp_generator' ); // removes WP version meta tag
remove_filter( 'the_content', [ $some_object, 'method_name' ], 10 );
Hook priorities run lowest-to-highest (default 10). To run after most plugins, use 99 or 100. To run before, use 1 or 5.
Key Hooks to Know
| Hook | Type | When it fires |
|---|---|---|
init | action | After WordPress loads, before headers sent |
wp_loaded | action | After all plugins and theme loaded |
template_redirect | action | Before template is chosen — redirect here |
wp_enqueue_scripts | action | Correct place to register/enqueue assets |
save_post | action | When any post type is saved |
pre_get_posts | action/filter | Modify WP_Query before it runs |
the_content | filter | Filter post content before display |
wp_nav_menu_items | filter | Modify navigation HTML |
upload_mimes | filter | Allow/block file types in Media |
05 — Custom Post Types & Taxonomies Without a Plugin
Register CPTs in a plugin file (never in functions.php for anything serious):
add_action( 'init', function() {
register_post_type( 'property', [
'labels' => [ 'name' => 'Properties', 'singular_name' => 'Property' ],
'public' => true,
'has_archive' => true,
'supports' => [ 'title', 'editor', 'thumbnail', 'custom-fields' ],
'menu_icon' => 'dashicons-building',
'rewrite' => [ 'slug' => 'properties' ],
'show_in_rest' => true, // required for Gutenberg support
]);
register_taxonomy( 'property_type', 'property', [
'hierarchical' => true, // true = like categories, false = like tags
'public' => true,
'rewrite' => [ 'slug' => 'property-type' ],
'show_in_rest' => true,
]);
});
After registering, flush rewrite rules once by visiting Settings → Permalinks, or via WP-CLI:
wp rewrite flush
06 — The Block Editor (Gutenberg): What's Actually Happening
Gutenberg stores content as HTML with structured comments. A paragraph block looks like this in the database:
<!-- wp:paragraph {"dropCap":true} -->
<p>Your content here.</p>
<!-- /wp:paragraph -->
This means:
- Classic editor content still works — it's just stored as plain HTML without block comments. Don't fear migration.
- Block attributes live in the comment, not the HTML. Querying or transforming block content requires parsing those comments, not just the HTML.
- The Block API is JavaScript-first. Custom blocks are React components registered with
registerBlockType(). Server-side rendering is available for dynamic blocks. - Block patterns (reusable layout templates) and block variations (pre-configured versions of a block) are the right way to create starter layouts — not custom blocks with hardcoded markup.
To register a server-rendered block without a full webpack build:
// In your plugin's init callback:
register_block_type( 'myplugin/latest-news', [
'render_callback' => 'myplugin_render_latest_news',
'attributes' => [
'count' => [ 'type' => 'integer', 'default' => 3 ],
],
]);
function myplugin_render_latest_news( $attrs ) {
$posts = get_posts([ 'numberposts' => $attrs['count'] ]);
// Build and return HTML
}
07 — The REST API
WordPress ships with a full REST API at /wp-json/wp/v2/. You can use it for headless setups, mobile apps, or JavaScript-heavy front ends.
Useful Endpoints
GET /wp-json/wp/v2/posts?per_page=10&categories=5
GET /wp-json/wp/v2/posts/{id}
POST /wp-json/wp/v2/posts (requires auth)
GET /wp-json/wp/v2/users/me (requires auth)
Authentication Options
- Application Passwords (built-in since 5.6) — generate a password per app in Users → Profile. Send as
Authorization: Basic base64(username:app-password). - JWT Authentication — requires a plugin (JWT Auth by Useful Team is popular). Better for mobile apps.
- Cookie + nonce — for JavaScript running on the same site. WordPress injects
wpApiSettings.nonceinto the page; pass it asX-WP-Nonceheader.
Extending the API
// Add a custom field to the posts endpoint
register_rest_field( 'post', 'reading_time', [
'get_callback' => function( $post_arr ) {
$words = str_word_count( strip_tags( $post_arr['content']['rendered'] ) );
return ceil( $words / 200 ); // minutes
},
'schema' => [ 'type' => 'integer' ],
]);
// Register a fully custom endpoint
add_action( 'rest_api_init', function() {
register_rest_route( 'myplugin/v1', '/stats', [
'methods' => 'GET',
'callback' => 'myplugin_stats_handler',
'permission_callback' => '__return_true', // or check capabilities
]);
});
08 — Multisite
WordPress Multisite turns a single install into a network of sites sharing one codebase and one set of plugins/themes (though activation is per-site or network-wide).
Enable it by adding to wp-config.php before /* That's all, stop editing! */:
define( 'WP_ALLOW_MULTISITE', true );
Then visit Tools → Network Setup and follow the instructions. You'll add constants to wp-config.php and rewrite rules to .htaccess or your Nginx config.
Architectural Realities
- Each site gets its own set of database tables (
wp_2_posts,wp_3_posts, etc.) but shareswp_usersandwp_usermeta. - Plugins installed network-wide run on every site. Site-level activation only affects that site.
- Super Admin is a separate role above Administrator. Only Super Admins can install plugins/themes.
- Domain mapping (separate domains per subsite) requires either a plugin or server-level configuration.
switch_to_blog( $blog_id )lets you run queries against another site in the network; always callrestore_current_blog()afterward.
09 — Security Hardening Checklist
Beyond "keep everything updated":
File System
- Set
wp-config.phppermissions to600(owner read/write only) - Set directory permissions to
755, file permissions to644 - Ensure the web server user cannot write to
wp-includes/orwp-admin/ - Block direct PHP execution in
wp-content/uploads/via.htaccessor Nginx:
location ~* /uploads/.*\.php$ {
deny all;
}
Authentication
- Enforce strong passwords with the built-in strength meter plus a policy plugin
- Enable two-factor authentication (Two Factor plugin by George Stephanis is audited and free)
- Limit login attempts (Limit Login Attempts Reloaded, or your host's WAF)
- Disable XML-RPC if you don't use Jetpack or the mobile app:
add_filter( 'xmlrpc_enabled', '__return_false' ); - Remove the username from
?author=1enumeration by redirecting author archive queries
Monitoring
- Enable email alerts for new admin user creation
- Log file changes (Wordfence or a standalone file integrity monitor)
- Set up uptime monitoring separately from your host
Security Headers
Add at the server level or via a plugin:
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: geolocation=(), microphone=()
10 — Deployment & Version Control
Never edit production files directly. A proper workflow looks like:
- Local development (LocalWP, Lando, or DDEV)
- Git for all code — theme, custom plugins,
wp-config.php(with secrets excluded via.envor server environment variables) - Staging environment for testing
- Deploy via git pull, rsync, or a CI/CD pipeline (GitHub Actions + WP-CLI is a common setup)
What Goes in Git
✅ Custom theme files
✅ Custom plugin files
✅ composer.json / composer.lock
✅ wp-config.php (without credentials — use environment variables)
✅ .htaccess / nginx.conf snippets
❌ wp-content/uploads/ (media library — sync separately or use offload to S3)
❌ wp-content/plugins/ (manage with Composer or deploy separately)
❌ wp-content/cache/
❌ node_modules/
Manage plugin dependencies with Composer + wpackagist:
{
"repositories": [
{ "type": "composer", "url": "https://wpackagist.org" }
],
"require": {
"wpackagist-plugin/query-monitor": "^3.14",
"wpackagist-plugin/redis-cache": "^2.5"
}
}
11 — Child Themes vs. Plugins: The Right Tool
A common mistake is putting all custom code in functions.php. The rule of thumb:
- Functionality independent of the theme (custom post types, shortcodes, REST endpoints, admin tweaks) → plugin. If you change themes, the functionality survives.
- Visual or layout changes to a specific parent theme → child theme.
- Global front-end customisations tied to the site's design → child theme's
functions.phpis acceptable, but a site-specific plugin is still cleaner.
A minimal child theme needs only two files:
my-child-theme/
style.css ← must contain the Theme Name and Template header
functions.php ← enqueue parent stylesheet here
// functions.php in child theme
add_action( 'wp_enqueue_scripts', function() {
wp_enqueue_style(
'parent-style',
get_template_directory_uri() . '/style.css'
);
});
12 — Useful Patterns & Snippets
// Run code only on the front end, not in admin or REST
if ( ! is_admin() && ! wp_is_json_request() ) { ... }
// Get the current page's template file
$template = get_page_template_slug( get_the_ID() );
// Programmatically create or update a post
$post_id = wp_insert_post([
'post_title' => 'Auto-generated',
'post_content' => 'Content here',
'post_status' => 'publish',
'post_type' => 'post',
'meta_input' => [ '_custom_key' => 'value' ],
]);
// Query posts by meta value efficiently (use indexed meta keys)
$query = new WP_Query([
'post_type' => 'property',
'meta_query' => [
[ 'key' => '_price', 'value' => 500000, 'compare' => '<=', 'type' => 'NUMERIC' ],
],
'orderby' => 'meta_value_num',
'meta_key' => '_price',
'order' => 'ASC',
]);
// Send a transactional email using wp_mail with HTML
add_filter( 'wp_mail_content_type', fn() => 'text/html' );
wp_mail( 'user@example.com', 'Subject', '<p>Hello <strong>World</strong></p>' );
remove_filter( 'wp_mail_content_type', fn() => 'text/html' ); // reset immediately
// Schedule a one-off event
if ( ! wp_next_scheduled( 'myplugin_process_queue' ) ) {
wp_schedule_single_event( time() + 300, 'myplugin_process_queue' );
}
add_action( 'myplugin_process_queue', 'myplugin_do_processing' );
13 — Debugging Toolkit
| Tool | What it's for |
|---|---|
| Query Monitor | Queries, hooks, conditionals, HTTP requests per page load |
WP-CLI wp profile | Hook-level profiling from the command line |
| Xdebug | Step-through PHP debugging in a local environment |
| Health Check & Troubleshooting | Plugin conflict isolation without affecting visitors |
| Log Viewer | Read debug.log from the dashboard |
| Browser DevTools → Network | REST API calls, asset loading, TTFB |
Enable structured logging in your own code:
// Only logs when WP_DEBUG_LOG is true
if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
error_log( print_r( $data, true ) );
}
For persistent, structured logs in production, consider routing through a logging plugin that writes to a dedicated table or an external service (Papertrail, Logtail).
WordPress is a platform, not just a CMS. The more you understand what's happening under the hood, the more predictable and controllable it becomes.