WordPress: Beyond the Basics
A reference for experienced users who know their way around the dashboard and want to go deeper.
01Performance & 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.
02wp-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
03WP-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.
04The 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 |
05Custom 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
06The 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 class="has-drop-cap">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
}
07The 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
]);
});
08Multisite
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.
09Security 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=()
10Deployment & 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"
}
}
11Child 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'
);
});
12Useful 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' );
13Debugging 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).