I’ve been building WordPress plugins for over six years. For the last year or so, Claude has been part of my daily workflow. Not in the “AI does everything” way you see in Twitter threads — more like having a senior developer available at any hour who’s read every piece of WordPress documentation and has strong opinions about code quality.
This post is about what actually works, what doesn’t, and how I’ve set things up so Claude is genuinely useful rather than just fast at producing plausible-looking code I then have to fix.
What I use Claude for in plugin development
Let me be direct about where Claude earns its place in my workflow and where I’ve stopped bothering.
Scaffolding plugin structure
Starting a new plugin used to mean copying from an old one and spending 20 minutes cleaning up the parts that don’t apply. Now I describe what the plugin needs to do and ask Claude to scaffold the folder structure, main plugin file, and class skeleton. The output isn’t always exactly how I’d write it, but it gets me to 80% in two minutes instead of twenty.
The prompt I use for this:
I'm building a WordPress plugin called "Plugin Name" with these requirements:
- PSR-4 autoloading, namespace PluginNamespace\
- Main plugin file: mainfile.php
- Admin settings page using the Settings API
- A REST API endpoint to serve the feed as XML
- Freemium structure (free core, pro features behind a license check)
Generate the folder structure and main plugin file with proper plugin headers,
autoloading setup, and singleton pattern. Use WordPress coding standards.BashThat level of specificity matters. Vague prompts get vague scaffolding. The more context you give — namespace, architecture decisions, specific WordPress APIs you want used — the less cleanup you do afterward.
Writing hooks and filters
This is where Claude saves me the most time. WordPress has thousands of hooks, and remembering the exact signature, priority, and accepted arguments for each one is genuinely tedious. I’ll describe what I need to happen and ask for the hook implementation:
I need to add a custom column to the WooCommerce orders list that shows a custom
meta field called _feed_export_status. The column should display "Exported" or
"Pending" based on the meta value. Use the correct HPOS-compatible hooks.BashClaude knows the HPOS column hooks versus the legacy post-based ones, which order the filter arguments come in, and that I need to handle both admin and AJAX contexts. I still review the output — I’ve caught wrong priority numbers and the occasional deprecated function — but the structure is always right.
Debugging PHP errors
This is the one I was most skeptical about. Pasting an error log into a chat window felt like a long shot. In practice it works better than I expected, especially for errors in WooCommerce or WordPress core where the stack trace points to a function I didn’t write.
What I do: paste the full error with stack trace, then paste the relevant section of my code, then ask what’s wrong. The key is not asking “what’s wrong with my code” in the abstract — Claude needs the actual error text and the actual code. Half the time it spots something I was looking right at and missing.
Where it fails: when the bug is in how two plugins interact, or when the error is caused by something upstream in WordPress core that my code is reacting to. For those I still rely on Query Monitor and old-fashioned var_dump().
Security review
Before I ship any form handler or AJAX endpoint, I paste it into Claude and ask specifically: “Review this for missing nonce verification, unsanitized input, and unescaped output.” Not a general “is this secure” question — a specific checklist. The response catches things. Not everything, but enough that I now do this as a standard step.
I wrote a full guide on WordPress plugin security covering nonces, sanitization, and escaping — Claude helped me review every code example in that post before publishing.
Writing documentation
PHPDoc blocks, README files, inline comments. I write the code, then ask Claude to document it. I give it the actual function and tell it the audience — “write PHPDoc for this, the audience is developers extending this plugin via filters.” The output is good and I spend maybe two minutes editing it rather than ten minutes writing it from scratch.
How I set Claude up for WordPress work
The biggest improvement to my results came from using Claude Skills. A Skill is a markdown file that loads into Claude’s context before you start working — essentially a reference document it can consult while helping you. I have a WordPress development skill that contains my preferred conventions: namespace patterns, how I structure plugin files, which sanitization functions to use for which data types, my preferred hook registration pattern.
Before I set that up, Claude would occasionally suggest patterns that work fine in generic WordPress but don’t fit how I actually build things. After — the scaffolding matches my conventions, the security patterns match what I use, and I spend less time saying “actually I prefer it this way.”
I’ll cover Skills properly in a separate post — what they are and how to build one — but the short version is: if you use Claude regularly for a specific type of work, a Skill is worth the hour it takes to write.
What doesn’t work well
I want to be honest about the limitations because most AI content skips this part.
Claude doesn’t know your codebase. Every conversation starts fresh. For anything that requires understanding how your specific plugin’s classes relate to each other — a refactor that touches eight files, a bug that spans multiple systems — you have to paste a lot of context and even then it’s working with an incomplete picture. Claude Code (the CLI tool) handles this better because it can read your actual files, but in the chat interface you’re always manually providing context.
It sometimes hallucinates WordPress functions. Not often, but it happens — usually with less common APIs or very recent additions. I’ve been given a function name that doesn’t exist, a hook that fires in a different place than described, an argument order that’s wrong. The fix is simple: always test the code before shipping it. But if you’re copying Claude’s output directly to production without checking, you will eventually have a bad day.
It’s not great at architectural decisions. “Should this be a custom post type or a custom database table?” is a question where I want a developer who knows my project, my scale, and my client’s technical team. Claude will give you an answer but it’s necessarily based on incomplete information. For decisions that are hard to undo, I trust my own judgment or ask a human developer.
My actual prompting approach
A few things I’ve learned that changed my results significantly:
Give it the context it needs to care about. “Write a WordPress function” is almost useless. “Write a WordPress function for a commercial plugin targeting WooCommerce stores on PHP 8.1+, following WordPress coding standards, with proper sanitization and a nonce check” is what actually produces code I can use.
Ask for one thing at a time. “Build me a complete settings page with AJAX saving, a custom REST endpoint, and email notifications” produces worse code than asking for each piece separately. Claude tries to do everything at once and cuts corners. Break it up.
Tell it what you don’t want. “Don’t use jQuery, don’t use deprecated functions, don’t add inline styles” cuts out a surprising amount of cleanup.
Ask it to explain before it writes. For anything non-trivial, I ask “before writing the code, explain how you’d approach this.” If the approach is wrong, I correct it before it writes 100 lines I have to redo. This sounds slower but it isn’t.
A real example: building a meta box
Here’s a prompt that produced code I used almost unchanged:
I need a meta box for a custom post type called 'event'. The meta box should:
- Appear on the event edit screen, titled "Event Details"
- Have three fields: event_date (date picker), event_location (text), event_capacity (number)
- Save the fields on post save with nonce verification and sanitization
- Load saved values when the edit screen loads
Use add_meta_box(), a class-based approach with the constructor registering
hooks, proper nonce field naming, absint() for capacity, sanitize_text_field()
for location, sanitize_text_field() for date. No jQuery — use the browser's
native date input.BashThe output needed one small fix (it used get_post_meta without the third argument defaulting correctly) but otherwise went straight into the plugin. Total time: about 90 seconds. Writing that from scratch would have taken 10–15 minutes.
The meta box pattern itself is covered in detail in the WordPress custom meta box guide if you want to understand what Claude produced before using it.
Is it worth it?
For WordPress plugin development specifically: yes, with caveats. It’s fastest for things with clear patterns — hooks, settings pages, meta boxes, REST endpoints — where WordPress has a defined way to do something and Claude knows it. It’s slower than just doing it yourself for things that require judgment, project-specific knowledge, or architectural thinking.
I’d estimate it saves me two to three hours per week on a typical plugin project. Not because it writes all the code, but because it eliminates the friction of looking up exact API signatures, writing boilerplate I’ve written a hundred times before, and doing a first pass on documentation.
The developers I know who get the most out of it treat it like a capable junior who needs supervision rather than an oracle who’s always right. You review the work. You catch the mistakes. The difference is you’re catching them in 30 seconds instead of writing the original code from scratch over 15 minutes. That’s the actual value — not intelligence, just speed on the boring parts.
Frequently asked questions
Which Claude model should I use for coding?
Claude Sonnet 4 is my default — it’s fast enough that the wait doesn’t break your flow, and the code quality is good for most WordPress work. For complex architectural questions or code reviews where I want it to think harder, I’ll switch to Claude Opus. For quick lookups (“what’s the hook for X”) Sonnet is more than enough.
Is Claude better than GitHub Copilot for WordPress development?
They’re different tools. Copilot works inline in your editor — it autocompletes as you type. Claude is better for longer conversations: “here’s my class, here are the requirements, write the next method.” I use both. Copilot for line-by-line autocomplete, Claude for anything that needs context or explanation. Trying to pick one over the other misses that they solve different friction points.
Does Claude know about recent WordPress or WooCommerce changes?
It has a knowledge cutoff, so very recent releases may not be fully covered. For anything that changed in the last six months — HPOS order storage, the Interactivity API, new block editor APIs — I verify against the official docs rather than taking Claude’s output on faith. For established patterns (hooks, the Settings API, WP_Query) the knowledge is solid.
Can I use Claude for WooCommerce extension development specifically?
Yes, and it’s quite good at it. WooCommerce has well-documented patterns and Claude knows them — WooCommerce hooks, the WC_Product class API, payment gateway structure, the order data model. The same caveat applies: anything that changed very recently needs verification against WooCommerce’s developer docs.
Custom post types are how you tell WordPress that your data isn’t a blog post. Products, events, team members, properties, courses — any content with its own structure and its own admin screen needs a custom post type. The same goes for taxonomies: if you need to categorise that content in a way that doesn’t fit default categories and tags, you register a custom taxonomy.
Both get registered on init with a single function call. The WordPress docs list every possible argument, which makes both functions look intimidating — most of those arguments have sensible defaults and you’ll rarely touch them. This guide focuses on the ones you’ll actually configure.
Registering a custom post type
Here’s a real-world example — a plugin that manages events:
add_action( 'init', 'myplugin_register_event_post_type' );
function myplugin_register_event_post_type(): void {
$labels = [
'name' => 'Events',
'singular_name' => 'Event',
'add_new' => 'Add New',
'add_new_item' => 'Add New Event',
'edit_item' => 'Edit Event',
'new_item' => 'New Event',
'view_item' => 'View Event',
'search_items' => 'Search Events',
'not_found' => 'No events found',
'not_found_in_trash' => 'No events found in trash',
'menu_name' => 'Events',
];
register_post_type( 'event', [
'labels' => $labels,
'public' => true,
'has_archive' => true,
'rewrite' => [ 'slug' => 'events' ],
'supports' => [ 'title', 'editor', 'thumbnail', 'excerpt', 'custom-fields' ],
'menu_icon' => 'dashicons-calendar-alt',
'menu_position'=> 5,
'show_in_rest' => true, // required for Gutenberg support
] );
}
PHPThe post type key — 'event' — must be 20 characters or fewer, lowercase, no spaces. It’s stored in the database, so changing it later means migrating data. Pick it carefully the first time.
The arguments that matter most
| Argument | What it does | Default |
|---|---|---|
public | Makes the CPT visible in the admin and on the front end | false |
has_archive | Enables an archive page at /events/ | false |
rewrite | Controls the URL slug for single posts | post type key |
supports | Which editor fields to show (title, editor, thumbnail, etc.) | ['title','editor'] |
show_in_rest | Exposes the CPT via REST API and enables Gutenberg | false |
menu_icon | Dashicon slug or SVG data URI for the admin menu | generic post icon |
capability_type | Base for permission checks ('post' reuses post caps) | 'post' |
hierarchical | Set true for page-like parent/child structure | false |
One thing people miss: show_in_rest => true isn’t optional anymore. Without it, the Gutenberg editor won’t load for your post type — you’ll get the classic editor instead. If you’re building anything in 2026, always set this to true.
Custom capabilities
By default, capability_type => 'post' means WordPress reuses the standard post permissions — anyone who can edit posts can edit your CPT. That’s fine for most cases. But if you need separate permissions — so editors can manage events but not delete them, for example — use a custom capability type:
register_post_type( 'event', [
// ...
'capability_type' => 'event',
'map_meta_cap' => true, // let WordPress map meta caps for you
] );
// Then grant the capabilities to roles on activation
register_activation_hook( __FILE__, function() {
$admin = get_role( 'administrator' );
$admin->add_cap( 'edit_events' );
$admin->add_cap( 'publish_events' );
$admin->add_cap( 'delete_events' );
// ... etc
} );
PHPAlways set map_meta_cap => true when using a custom capability_type. Without it, WordPress can’t resolve capabilities like edit_post for your CPT and will silently deny access in unexpected places.
Registering a custom taxonomy
Taxonomies work the same way — one function on init. The third argument links the taxonomy to one or more post types:
add_action( 'init', 'myplugin_register_event_type_taxonomy' );
function myplugin_register_event_type_taxonomy(): void {
$labels = [
'name' => 'Event Types',
'singular_name' => 'Event Type',
'search_items' => 'Search Event Types',
'all_items' => 'All Event Types',
'edit_item' => 'Edit Event Type',
'update_item' => 'Update Event Type',
'add_new_item' => 'Add New Event Type',
'new_item_name' => 'New Event Type Name',
'menu_name' => 'Event Types',
];
register_taxonomy( 'event_type', 'event', [
'labels' => $labels,
'hierarchical' => true, // true = category-like, false = tag-like
'public' => true,
'rewrite' => [ 'slug' => 'event-type' ],
'show_in_rest' => true, // required for Gutenberg support
'show_admin_column' => true, // shows taxonomy column in post list table
] );
}
PHPYou can attach a taxonomy to multiple post types by passing an array as the second argument: [ 'event', 'post' ]. This is useful when you want to share a taxonomy across content types — a “location” taxonomy that applies to both events and venues, for instance.
Hierarchical vs non-hierarchical
hierarchical => true gives you a checkbox UI in the editor, parent/child relationships, and behaviour like categories. hierarchical => false gives you a text input with autocomplete, no parent/child, and behaviour like tags. Pick based on how your users will actually add terms — if they’ll be choosing from a fixed list, hierarchical is usually cleaner. If they’ll be typing freeform labels, non-hierarchical is better.
The rewrite rules problem
This trips up almost everyone the first time. After registering a custom post type, the archive and single URLs return 404 until you flush the rewrite rules. In development you can do this manually: Settings → Permalinks → Save Changes (without changing anything). In a plugin, flush on activation:
register_activation_hook( __FILE__, function() {
// Register the post type first
myplugin_register_event_post_type();
// Then flush — expensive operation, only do this on activation/deactivation
flush_rewrite_rules();
} );
register_deactivation_hook( __FILE__, function() {
unregister_post_type( 'event' );
flush_rewrite_rules();
} );
PHPNever call flush_rewrite_rules() on every request or inside init. It’s a database write that rebuilds the entire rewrite rule table — doing it repeatedly will noticeably slow down the site. Activation and deactivation are the only appropriate places.
Querying your custom post type
Once registered, you can query your CPT with WP_Query exactly like built-in post types. For the full argument reference, the WP_Query complete guide covers every available parameter including taxonomy queries:
$events = new WP_Query( [
'post_type' => 'event',
'posts_per_page' => 10,
'post_status' => 'publish',
'tax_query' => [
[
'taxonomy' => 'event_type',
'field' => 'slug',
'terms' => 'conference',
],
],
'meta_key' => 'event_start_date',
'orderby' => 'meta_value',
'order' => 'ASC',
'no_found_rows' => true,
] );
PHPTo include your CPT in the main query on archive pages, use pre_get_posts rather than a secondary query. The pre_get_posts guide covers this pattern — it’s more efficient than creating a secondary WP_Query on the template.
Putting it all together in a plugin
A clean structure for a plugin that registers a CPT and taxonomy together — keeping registration, activation, and deactivation in one place:
php
/**
* Plugin Name: Event Manager
* Description: Registers the Event custom post type and Event Type taxonomy.
* Version: 1.0.0
*/
if ( ! defined( 'ABSPATH' ) ) exit;
function myplugin_register_types(): void {
// Post type
register_post_type( 'event', [
'labels' =--> [
'name' => 'Events',
'singular_name' => 'Event',
'add_new_item' => 'Add New Event',
'edit_item' => 'Edit Event',
'not_found' => 'No events found',
],
'public' => true,
'has_archive' => true,
'rewrite' => [ 'slug' => 'events' ],
'supports' => [ 'title', 'editor', 'thumbnail', 'excerpt' ],
'menu_icon' => 'dashicons-calendar-alt',
'show_in_rest' => true,
] );
// Taxonomy
register_taxonomy( 'event_type', 'event', [
'labels' => [
'name' => 'Event Types',
'singular_name' => 'Event Type',
'add_new_item' => 'Add New Event Type',
],
'hierarchical' => true,
'public' => true,
'rewrite' => [ 'slug' => 'event-type' ],
'show_in_rest' => true,
'show_admin_column' => true,
] );
}
add_action( 'init', 'myplugin_register_types' );
register_activation_hook( __FILE__, function() {
myplugin_register_types();
flush_rewrite_rules();
} );
register_deactivation_hook( __FILE__, function() {
unregister_post_type( 'event' );
unregister_taxonomy( 'event_type' );
flush_rewrite_rules();
} );
PHPFor the WordPress plugin folder structure question — registration code like this belongs in a dedicated includes/class-post-types.php file rather than the main plugin file. The main file should only bootstrap; the actual registration logic lives in a class that gets instantiated on plugins_loaded.
Common mistakes
Forgetting show_in_rest => true and wondering why Gutenberg won’t load. Using a post type key longer than 20 characters (WordPress silently truncates it, which causes hard-to-debug queries). Calling flush_rewrite_rules() on every init. Using a post type key that conflicts with a built-in — post, page, attachment, revision, nav_menu_item, custom_css, customize_changeset, oembed_cache, user_request, wp_block, wp_template, and wp_template_part are all reserved.
And the one that costs the most debugging time: registering the taxonomy after the post type but then trying to query it before it’s associated. Always register both inside the same init callback, or at least make sure your taxonomy registration runs before any queries against it.
Frequently asked questions
What is the difference between a custom post type and a custom taxonomy?
A custom post type is a new content type — events, products, team members. A custom taxonomy is a classification system for that content — event types, product categories, departments. Post types store the actual content; taxonomies organise it. They work together: you register a taxonomy and attach it to one or more post types.
Do I need a plugin like CPT UI to register custom post types?
No. CPT UI is useful for quickly prototyping or for clients who need to manage their own post types, but for anything you’re shipping in a plugin or theme, register via code. UI-registered post types are stored in the database and disappear if the plugin is deactivated. Code-registered ones are always there when your plugin is active.
Why does my custom post type archive return a 404?
Rewrite rules haven’t been flushed. Go to Settings → Permalinks and click Save Changes. If you’re doing this programmatically, call flush_rewrite_rules() inside your activation hook after registering the post type.
Can I add a custom post type to the main blog query?
Yes, with pre_get_posts. Add the CPT to the post_type argument on the main query for the home page or archive. Don’t set 'exclude_from_search' => false and expect it to appear automatically — that only controls search, not the main blog index.
How do I show a custom taxonomy filter in the admin post list?
Set 'show_admin_column' => true when registering the taxonomy — this adds a column to the post list table. For the dropdown filter above the list, hook into restrict_manage_posts and render a with your taxonomy terms, then use parse_query to apply the filter. WordPress doesn’t do this automatically for custom taxonomies.
Block development has a reputation for being harder than it needs to be. The official docs are thorough but long, and it’s easy to get lost before writing a single line of JavaScript. I’ve watched developers spend an entire afternoon trying to configure Webpack before giving up. This guide skips that problem entirely.
The short version: you don’t need to configure Webpack yourself anymore. @wordpress/scripts handles the build tooling, and block.json handles block registration. Most tutorials you’ll find predate this setup, which is why they feel more complicated than they should be. The current approach is genuinely simple once you see it.
What you need before starting
Node.js (v18 or above) and npm installed locally. A WordPress development environment — Local by Flywheel or WordPress Playground both work fine. That’s genuinely it. No Docker, no Vagrant, no custom Webpack config sitting in your project waiting to break on the next Node upgrade.
Scaffold the plugin
The fastest way to start is with @wordpress/create-block, which scaffolds everything — plugin file, block.json, JavaScript, PHP, CSS, and build config — in one command:
cd wp-content/plugins
npx @wordpress/create-block my-first-block --namespace myfirstblockBashThis creates a my-first-block/ folder with the following structure:
my-first-block/
├── my-first-block.php # Plugin header + register_block_type call
├── package.json # @wordpress/scripts dependency
├── src/
│ ├── block.json # Block metadata (name, attributes, supports)
│ ├── edit.js # Editor UI (what the author sees)
│ ├── save.js # Front-end output (what visitors see)
│ ├── index.js # Registers the block in JS
│ └── style.scss # Front-end styles
└── build/ # Compiled output (generated, don't edit)BashActivate the plugin in WordPress, then start the dev server:
cd my-first-block
npm install
npm startBashFrom here, changes to files in src/ compile automatically. You’ll see the block appear in the editor under “Widgets” by default.
Understanding block.json
block.json is the single most important file to understand. It defines everything about the block — its name, what attributes it stores, what settings appear in the sidebar, and which JavaScript and CSS files to load. WordPress reads this file directly; you don’t register anything manually in PHP anymore.
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "myfirstblock/notice",
"version": "1.0.0",
"title": "Notice",
"category": "text",
"icon": "info",
"description": "A simple notice block with a message and colour option.",
"attributes": {
"message": {
"type": "string",
"source": "html",
"selector": "p"
},
"backgroundColor": {
"type": "string",
"default": "#f0f4ff"
}
},
"supports": {
"html": false,
"align": true,
"color": {
"background": true,
"text": true
},
"typography": {
"fontSize": true
}
},
"editorScript": "file:./index.js",
"editorStyle": "file:./index.css",
"style": "file:./style-index.css"
}JSONA few things worth knowing here. The name field must be unique — namespace/block-name format, all lowercase. The attributes object defines what data the block stores and how it maps to the HTML output (the source field). The supports object controls which panel options appear in the editor sidebar — setting "html": false prevents users from switching to HTML view, which is good practice for blocks with structured output.
The edit function
edit.js controls what the block looks like inside the editor. It can be interactive — you can add controls, let users type directly into the block, pick colours. This is all React, but you don’t need to import React itself; WordPress makes it available globally.
import { useBlockProps, RichText, InspectorControls } from '@wordpress/block-editor';
import { PanelBody, ColorPicker } from '@wordpress/components';
export default function Edit( { attributes, setAttributes } ) {
const { message, backgroundColor } = attributes;
const blockProps = useBlockProps( {
style: { backgroundColor },
} );
return (
<>
{ /* Sidebar panel */ }
<inspectorcontrols>
<panelbody title="Background colour" initialopen="{" true="" }="">
color="{" backgroundcolor="" }="" onchange="{" (="" value="" )=""> setAttributes( { backgroundColor: value } ) }
/>
colorpicker>panelbody>
inspectorcontrols>
{ /* Block canvas */ }
<div {="" ...blockprops="" }="">
<richtext tagname="p" value="{" message="" }="" onchange="{" (="" )=""> setAttributes( { message: value } ) }
placeholder="Write your notice here..."
/>
richtext>div>
);
}JavaScriptuseBlockProps() is required — it attaches the data attributes and class names WordPress needs to identify the block in the editor. Always spread it onto your outermost element. RichText gives you an editable text field with basic formatting. InspectorControls renders into the right sidebar when the block is selected.
The save function
save.js produces the static HTML that gets stored in the database. It runs once when the post is saved, not on every page load. The output needs to be deterministic — given the same attributes, it must always produce the same HTML, otherwise WordPress will flag a “block validation error” when the post is edited later.
import { useBlockProps, RichText } from '@wordpress/block-editor';
export default function Save( { attributes } ) {
const { message, backgroundColor } = attributes;
const blockProps = useBlockProps.save( {
style: { backgroundColor },
} );
return (
<div {="" ...blockprops="" }="">
<richtext.content tagname="p" value="{" message="" }="">
Notice the difference: in save.js you use useBlockProps.save() (not just useBlockProps()), and RichText.Content instead of RichText. These are the static versions. No event handlers, no state — just HTML output.
Registering the block in PHP
The main plugin file is short. register_block_type reads block.json and handles everything — you just point it at the build directory:
php
/**
* Plugin Name: My First Block
* Plugin URI: https://kamalhosen.com
* Description: A simple custom Gutenberg block.
* Version: 1.0.0
* Author: Kamal Hosen
* License: GPL-2.0-or-later
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
add_action( 'init', function() {
register_block_type( __DIR__ . '/build' );
} );PHPThat’s the entire PHP side. register_block_type finds the block.json inside the build directory, registers the block, and enqueues the compiled scripts and styles automatically. No manual wp_enqueue_script() calls needed — the WordPress options and settings you defined in block.json handle everything.
Building for production
# Development (watch mode, unminified)
npm start
# Production (minified, optimised)
npm run buildBashAlways run npm run build before distributing or deploying your plugin. The build/ directory is what WordPress actually loads — commit it to your repository. The node_modules/ folder goes in .gitignore.
Adding a dynamic block (server-side rendering)
Static blocks store their HTML in the database. Dynamic blocks render on each page request using a PHP callback — useful when the output depends on live data, like a recent posts list or a product feed.
To make a block dynamic, set save.js to return null and add a render_callback in PHP:
// save.js — return null for dynamic blocks
export default function Save() {
return null;
}JavaScriptadd_action( 'init', function() {
register_block_type( __DIR__ . '/build', [
'render_callback' => 'myfirstblock_render_notice',
] );
} );
function myfirstblock_render_notice( array $attributes, string $content ): string {
$message = wp_kses_post( $attributes['message'] ?? '' );
$bg_color = sanitize_hex_color( $attributes['backgroundColor'] ?? '#f0f4ff' );
return sprintf(
'%s
',
esc_attr( $bg_color ),
$message
);
}PHPAlways sanitize attributes in the PHP render callback — treat them the same way you’d treat any user input. See the WordPress plugin security guide for which sanitization function to use for each data type.
Block validation errors — what they are and why they happen
This is the thing that catches everyone at least once. If you change save.js after a block has already been used in posts, WordPress throws a validation error when those posts are opened. The stored HTML no longer matches what save() produces, and WordPress refuses to render the block without intervention.
There are three ways to handle this. The cleanest is to use a dynamic block (PHP render) from the start — then save always returns null and there’s nothing to validate. The second option is block deprecations, where you define the old save function alongside the new one and WordPress migrates the content automatically. The third option is to just bump the block name — useful during development when you haven’t shipped to anyone yet.
For any block that stores user content, dynamic rendering is generally the right default. Static blocks are best for purely presentational elements where the output is unlikely to ever change.
Useful @wordpress packages to know
| Package | What it gives you |
|---|---|
@wordpress/block-editor | useBlockProps, RichText, InspectorControls, BlockControls, colour/alignment tools |
@wordpress/components | UI components: PanelBody, TextControl, ToggleControl, SelectControl, Button |
@wordpress/data | Access to the editor data store — current post, editor settings, selected block |
@wordpress/i18n | __( 'string', 'textdomain' ) for translations |
@wordpress/api-fetch | Fetch data from the WordPress REST API with nonce auth built in |
@wordpress/hooks | JavaScript version of WordPress actions and filters — addFilter, addAction |
All of these are pre-bundled with WordPress and available as globals — you import them but don’t bundle them, which is why block JavaScript files are small despite using a full component library.
Frequently asked questions
Do I need to know React to build Gutenberg blocks?
You need enough JSX to read and write the edit function — props, state via setAttributes, conditional rendering. You don’t need to know React deeply. The block editor components handle most of the complexity, so most blocks are just a few components wired together rather than custom React logic.
What is the difference between apiVersion 2 and 3?
apiVersion 3 (introduced in WordPress 6.3) moves the block wrapper element handling into the editor iframe rather than the main document, which isolates block styles better. It’s the current default from @wordpress/create-block. Use it unless you’re supporting older WordPress versions below 6.3.
Can I build a Gutenberg block without @wordpress/scripts?
Yes — you can write vanilla JavaScript with no build step and register it with wp_enqueue_script. This works for simple blocks but gets painful quickly because you lose JSX, modern JS syntax, and the import system. For anything beyond a trivial block, @wordpress/scripts is worth the setup time, which is about two minutes with create-block.
How do I add a block to a specific category or create my own category?
Set the category field in block.json to one of the built-in values: text, media, design, widgets, theme, or embed. To create a custom category, use the block_categories_all filter in PHP:
add_filter( 'block_categories_all', function( array $categories ): array {
return array_merge( $categories, [
[
'slug' => 'my-plugin',
'title' => 'My Plugin',
'icon' => null,
],
] );
} );PHPHow do I check if a block is rendering correctly on the front end?
Add the block to a post, publish it, then view the post while logged out. The front end always loads the compiled style-index.css and the PHP render output — not the editor version. If something looks wrong on the front end but correct in the editor, check that save.js and your PHP render callback are producing the same class names and structure.
Most WordPress security problems don’t come from WordPress itself — they come from plugins. A form that saves data without checking who submitted it, an AJAX handler that trusts user input, an admin page that any logged-in user can access. These are the patterns that get sites compromised.
This post covers the four security mechanisms every plugin developer needs to use consistently: nonces, sanitization, escaping, and capability checks. Each one closes a specific attack surface. Skip any one of them and you leave a gap.
Capability Checks: Who Can Do This?
The first question your plugin should ask on any sensitive action is whether the current user is allowed to perform it. WordPress handles this with current_user_can(), which checks against the built-in role and capability system.
// Basic capability check before processing anything
function my_plugin_save_settings(): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( 'You do not have permission to do this.' );
}
// safe to proceed
}
PHPmanage_options is the capability tied to the Administrator role. Here are the ones you’ll use most often:
| Capability | Who has it | Use when |
|---|---|---|
manage_options | Administrator | Plugin settings, site-wide config |
edit_posts | Editor, Author, Contributor | Creating or editing content |
publish_posts | Editor, Author | Publishing content |
edit_others_posts | Editor | Editing other users’ content |
manage_categories | Editor, Administrator | Category/taxonomy management |
upload_files | Author and above | Media uploads |
Always check the minimum capability needed for the action — don’t default to manage_options for everything just because it’s the most restrictive. If an action is relevant to editors, check edit_posts instead.
For REST API endpoints and AJAX handlers, the same rule applies:
// In a REST API callback
add_action( 'rest_api_init', function() {
register_rest_route( 'myplugin/v1', '/settings', [
'methods' => 'POST',
'callback' => 'my_plugin_update_settings',
'permission_callback' => function() {
return current_user_can( 'manage_options' );
},
] );
} );
// In an AJAX handler — check capability before doing anything else
add_action( 'wp_ajax_my_plugin_action', function() {
if ( ! current_user_can( 'edit_posts' ) ) {
wp_send_json_error( 'Insufficient permissions', 403 );
}
// proceed
} );
PHPNonces: Verifying the Request Is Legitimate
A capability check confirms who the user is. A nonce confirms that the request actually came from your form — not from a malicious third-party site that tricked the user into submitting something. This is how you prevent CSRF (Cross-Site Request Forgery) attacks.
The name “nonce” is slightly misleading in WordPress — unlike a true cryptographic nonce, WordPress nonces can be reused within their validity window (24 hours by default). Think of them as short-lived tokens that tie a specific action to a specific user session.
Nonces in Admin Forms
function my_plugin_render_settings_page(): void {
$options = get_option( 'my_plugin_options', [] );
$api_key = $options['api_key'] ?? '';
$webhook = $options['webhook_url'] ?? '';
$enabled = ! empty( $options['enabled'] );
?>
<div class="wrap">
<h1>h1>
<form method="post">
<table class="form-table">
<tbody><tr>
<th><label for="api_key">label>th>
<td>
<input type="text" id="api_key" name="api_key" value="$api_key ); ?>" class="regular-text">
td>
tr>
<tr>
<th><label for="webhook">label>th>
<td>
<input type="url" id="webhook" name="webhook_url" value="$webhook ); ?>" class="regular-text">
td>
tr>
<tr>
<th>th>
<td>
<input type="checkbox" name="enabled" value="1" php="" checked(="" $enabled="" );="" ?="">
>
td>
tr>
tbody>table>
form>
div>
PHP
Notice that every dynamic value going into the HTML uses the correct escaping function for its context — esc_html() for plain text nodes, esc_attr() for attribute values, esc_url() for URLs.
Direct Database Queries
If you ever bypass the WordPress API and write a custom SQL query using $wpdb, you need to use prepared statements. Without them, you’re open to SQL injection.
global $wpdb;
// Wrong — never do this
$results = $wpdb->get_results(
"SELECT * FROM {$wpdb->posts} WHERE post_author = " . $_GET['author_id']
);
// Correct — use prepare() with placeholders
$author_id = absint( $_GET['author_id'] ?? 0 );
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT ID, post_title FROM {$wpdb->posts} WHERE post_author = %d AND post_status = %s",
$author_id,
'publish'
)
);
PHP%d is the placeholder for integers, %s for strings, %f for floats. Never concatenate user input directly into a SQL string, even after sanitizing it — prepare() is the only safe approach here. For more on WordPress plugin development best practices beyond security, including code organization and performance, see the dedicated guide.
Putting It All Together
Here’s a complete AJAX handler that uses all four mechanisms correctly — capability check, nonce verification, sanitization, and escaped output:
add_action( 'wp_ajax_my_plugin_save_item', 'my_plugin_save_item_handler' );
function my_plugin_save_item_handler(): void {
// 1. Capability check
if ( ! current_user_can( 'edit_posts' ) ) {
wp_send_json_error( [ 'message' => 'Permission denied.' ], 403 );
}
// 2. Nonce check
if ( ! check_ajax_referer( 'my_plugin_save_item', 'nonce', false ) ) {
wp_send_json_error( [ 'message' => 'Security check failed.' ], 403 );
}
// 3. Sanitize incoming data
$title = sanitize_text_field( $_POST['title'] ?? '' );
$content = wp_kses_post( $_POST['content'] ?? '' );
$url = esc_url_raw( $_POST['url'] ?? '' );
$order = absint( $_POST['order'] ?? 0 );
if ( empty( $title ) ) {
wp_send_json_error( [ 'message' => 'Title is required.' ], 400 );
}
// 4. Do the work
$post_id = wp_insert_post( [
'post_title' => $title,
'post_content' => $content,
'post_status' => 'publish',
'post_type' => 'my_item',
'menu_order' => $order,
] );
if ( is_wp_error( $post_id ) ) {
wp_send_json_error( [ 'message' => $post_id->get_error_message() ], 500 );
}
if ( $url ) {
update_post_meta( $post_id, '_my_item_url', $url );
}
// 5. Return escaped output in the response
wp_send_json_success( [
'id' => $post_id,
'title' => esc_html( $title ),
] );
}
PHPThis pattern — check capability, verify nonce, sanitize input, do the work, escape output — applies to every form handler, AJAX callback, and REST endpoint you write. Once it becomes habit, you write it without thinking about it.
For the hook registration patterns used in the examples above, the WordPress actions and filters guide covers add_action(), priorities, and class-based hooks in detail. For how these security patterns fit into your overall WordPress plugin folder structure, keeping security logic in dedicated handler files makes auditing easier.
Quick Reference
| Task | Function |
|---|---|
| Check user permission | current_user_can( $capability ) |
| Add nonce to form | wp_nonce_field( $action, $name ) |
| Verify form nonce | wp_verify_nonce( $_POST[$name], $action ) |
| Create nonce for JS | wp_create_nonce( $action ) |
| Verify AJAX nonce | check_ajax_referer( $action, $query_arg, false ) |
| Sanitize plain text | sanitize_text_field() |
| Sanitize textarea | sanitize_textarea_field() |
| Sanitize email | sanitize_email() |
| Sanitize URL for storage | esc_url_raw() |
| Sanitize integer | absint() |
| Sanitize HTML content | wp_kses_post() |
| Escape for HTML output | esc_html() |
| Escape for attribute | esc_attr() |
| Escape URL for output | esc_url() |
| Escape for JavaScript | esc_js() |
| Safe SQL query | $wpdb->prepare() |
Frequently Asked Questions
What is the difference between sanitization and escaping in WordPress?
Sanitization cleans data when it comes in — from a form submission or URL parameter — before you process or store it. Escaping makes data safe when it goes out — before you display it in HTML, an attribute, or a URL. You need both. Sanitizing without escaping still leaves you open to XSS if stored data is rendered raw. Escaping without sanitizing means you might store malformed data in the database.
Do I need a nonce if I’m already checking capabilities?
Yes. A capability check confirms the user has permission. A nonce confirms the request actually came from your form in their browser — not from a malicious third-party site that submitted a request using their active session. They protect against different attack types and you need both.
When should I use wp_kses_post() vs sanitize_text_field()?
Use sanitize_text_field() for any field where you expect plain text — names, titles, API keys, option values. It strips all HTML tags. Use wp_kses_post() only when the field intentionally accepts HTML content, like a WYSIWYG editor output. Using wp_kses_post() on a plain text field is not harmful but it’s unnecessary; using sanitize_text_field() on rich content will strip the HTML the user entered.
How long does a WordPress nonce last?
By default, WordPress nonces are valid for 24 hours. After 12 hours they enter a second “tick” — WordPress still accepts them but considers them less fresh. You can change the lifespan using the nonce_life filter, but the default is fine for most use cases.
Should I sanitize data when reading it from the database?
You should escape it when outputting, but not re-sanitize it. If you sanitized correctly on save, the stored data is already clean. The reason to always escape on output is that you can’t guarantee every write path sanitized correctly — direct database edits, imports, or bugs in other code can leave unsanitized data in the database.
If you’ve ever tried to change what posts appear on a category archive or search results page, you’ve probably run into query_posts(). And if you’ve read anything about it, you’ve probably been told not to use it. That advice is correct — but the reason matters. query_posts() replaces the main query mid-execution, which breaks pagination and throws off anything else that depends on query state. The right tool is pre_get_posts.
pre_get_posts is an action hook that fires before WordPress runs its main database query. You get direct access to the WP_Query object and can change its parameters right there — no secondary query, no pagination headaches.
Basic Usage
You hook into pre_get_posts from your theme’s functions.php or a plugin file. The callback receives the WP_Query object by reference, so changes you make stick without needing to return anything.
add_action( 'pre_get_posts', function( WP_Query $query ) {
// Always check these two conditions first
if ( is_admin() || ! $query->is_main_query() ) {
return;
}
// Your modifications go here
if ( $query->is_home() ) {
$query->set( 'posts_per_page', 6 );
}
} );PHPThose two guards at the top are not optional. Without is_admin(), your changes will affect admin screens like the post list table. Without is_main_query(), they’ll also affect any secondary WP_Query instances running on the same page — like a recent posts widget. Both checks together mean your code only runs on the front-end main query.
Changing Posts Per Page on Specific Pages
add_action( 'pre_get_posts', function( WP_Query $query ) {
if ( is_admin() || ! $query->is_main_query() ) {
return;
}
// 12 posts on category archives
if ( $query->is_category() ) {
$query->set( 'posts_per_page', 12 );
return;
}
// 24 posts on the portfolio archive
if ( $query->is_post_type_archive( 'portfolio' ) ) {
$query->set( 'posts_per_page', 24 );
return;
}
// 5 results on search pages
if ( $query->is_search() ) {
$query->set( 'posts_per_page', 5 );
return;
}
} );PHPEach early return after a match keeps the conditions clean and avoids accidentally applying multiple rules to a single request.
Excluding Posts or Categories
A common need on blog homepages is hiding certain categories — news, announcements, or anything you don’t want in the main feed.
add_action( 'pre_get_posts', function( WP_Query $query ) {
if ( is_admin() || ! $query->is_main_query() ) {
return;
}
// Exclude category ID 7 from the blog homepage
if ( $query->is_home() ) {
$query->set( 'category__not_in', [ 7 ] );
}
} );PHPYou can also exclude specific posts by ID:
add_action( 'pre_get_posts', function( WP_Query $query ) {
if ( is_admin() || ! $query->is_main_query() ) {
return;
}
if ( $query->is_home() ) {
$query->set( 'post__not_in', [ 42, 57, 103 ] );
}
} );PHPIncluding Custom Post Types in Search
By default, WordPress search only covers the post post type. If you register a custom post type and want it to show up in search results, pre_get_posts is where you add it.
add_action( 'pre_get_posts', function( WP_Query $query ) {
if ( is_admin() || ! $query->is_main_query() ) {
return;
}
if ( $query->is_search() ) {
$query->set( 'post_type', [ 'post', 'portfolio', 'product' ] );
}
} );PHPPass an array to include multiple post types. Note that including product here means WooCommerce products appear in your theme’s native search results — which you may or may not want depending on your setup.
Filtering by Custom Taxonomy on Archives
add_action( 'pre_get_posts', function( WP_Query $query ) {
if ( is_admin() || ! $query->is_main_query() ) {
return;
}
// On the portfolio archive, only show published, client-approved work
if ( $query->is_post_type_archive( 'portfolio' ) ) {
$query->set( 'tax_query', [
[
'taxonomy' => 'portfolio_status',
'field' => 'slug',
'terms' => 'approved',
],
] );
}
} );PHPChanging the Sort Order
Say you want a custom post type archive sorted alphabetically instead of by date:
add_action( 'pre_get_posts', function( WP_Query $query ) {
if ( is_admin() || ! $query->is_main_query() ) {
return;
}
if ( $query->is_post_type_archive( 'speaker' ) ) {
$query->set( 'orderby', 'title' );
$query->set( 'order', 'ASC' );
}
} );PHPYou can set any orderby value that WP_Query accepts — date, title, menu_order, rand, meta_value_num, and so on. For a full list see the WP_Query complete guide.
Modifying Author Archives
add_action( 'pre_get_posts', function( WP_Query $query ) {
if ( is_admin() || ! $query->is_main_query() ) {
return;
}
// Only show posts (not pages or CPTs) on author archives
if ( $query->is_author() ) {
$query->set( 'post_type', 'post' );
}
} );PHPUsing pre_get_posts in a Plugin (Class-Based)
If you’re building a plugin rather than editing functions.php, put this inside a class. The pattern is the same — the hook registration just moves into your constructor. See the WordPress actions and filters guide for the full class-based hook pattern.
class My_Plugin_Query_Modifier {
public function __construct() {
add_action( 'pre_get_posts', [ $this, 'modify_queries' ] );
}
public function modify_queries( WP_Query $query ): void {
if ( is_admin() || ! $query->is_main_query() ) {
return;
}
if ( $query->is_post_type_archive( 'product' ) ) {
$query->set( 'posts_per_page', 24 );
$query->set( 'orderby', 'title' );
$query->set( 'order', 'ASC' );
}
}
}
new My_Plugin_Query_Modifier();PHPWhat pre_get_posts Can’t Do
There are a few things worth knowing upfront so you don’t spend time debugging something that won’t work here:
- It doesn’t affect secondary queries. If a widget or shortcode runs its own
WP_Query,pre_get_postsfires for that too — but youris_main_query()check correctly filters those out. If you need to modify a specific secondary query, you’d need a different approach (like passing arguments directly to that query). - It can’t change the queried object. If WordPress has already determined it’s on a category archive, you can’t use
pre_get_poststo make it behave like a tag archive. The conditional tags likeis_category()are already set by the time this hook fires. - It’s not the right place for redirects. Use
template_redirectfor that.
pre_get_posts vs a Secondary WP_Query
This comes up a lot. The short answer: if you’re changing what appears in the main loop on an archive or taxonomy page, use pre_get_posts. If you need a completely separate list of posts somewhere on the page — like a sidebar or a “related posts” section — use a secondary WP_Query or get_posts.
The reason pre_get_posts is preferred for the main loop is performance. Creating a secondary WP_Query in a template when you only needed to adjust the main query runs two database queries where one would have done the job.
Debugging pre_get_posts
If your changes aren’t taking effect, run through this checklist:
- Check that
is_admin()andis_main_query()guards are in place and aren’t filtering out the request you’re targeting. - Confirm the conditional tag is correct.
is_home()is the blog index, not the front page — if you have a static front page set, useis_front_page()for that. - Add a quick
var_dump( $query->query_vars )just before yourreturnto confirm the modified values are set. - Install Query Monitor — it shows the exact arguments of every query that ran on the page.
Frequently Asked Questions
What is the difference between pre_get_posts and query_posts?
query_posts() replaces the main query after it’s already been set up, which breaks pagination and corrupts the global query state. pre_get_posts modifies the query object before the database call is made, so pagination and conditional tags continue to work correctly. Never use query_posts() in production code.
Does pre_get_posts affect admin pages?
It fires on admin pages too, which is why the is_admin() check is essential. Without it, your changes could affect the posts list in the admin panel and cause confusing behaviour for editors.
Can I use pre_get_posts to add a meta query to the main loop?
Yes. Use $query->set( 'meta_query', [ ... ] ) exactly as you would when constructing a WP_Query directly. The same argument structure applies. For meta query syntax and examples, see the WP_Query complete guide.
Will pre_get_posts break pagination?
No — that’s actually the main reason to use it instead of query_posts(). Because it modifies the query before execution, WordPress calculates the correct total number of pages based on your modified arguments. Pagination works as expected.
Can I have multiple pre_get_posts hooks?
Yes. You can register multiple callbacks on the same hook with different priorities. Each one runs in order. Keep them focused — one callback per concern is easier to debug than one large callback that handles every case.
What Are WordPress Hooks?
WordPress hooks are the backbone of every plugin and theme. They let you run your own code at specific points in WordPress’s execution cycle — without touching core files. There are two types: actions and filters. Understanding the difference is the single most important concept in WordPress plugin development.
The short version: an action does something at a point in time. A filter modifies something and returns the modified value. Every hook in WordPress is one or the other.
Actions: Running Code at the Right Moment
An action hook fires at a specific point — when a post is saved, when the page header loads, when a user logs in. You attach your function to it with add_action(). Your function runs, does its work, and returns nothing.
// Basic action: add something to the footer
add_action( 'wp_footer', 'my_plugin_footer_script' );
function my_plugin_footer_script() {
echo '';
}
PHPThe add_action() function signature:
add_action( string $hook_name, callable $callback, int $priority = 10, int $accepted_args = 1 );
PHPPriority controls execution order when multiple callbacks are attached to the same hook. Lower number = runs earlier. Default is 10. If two callbacks have the same priority, they run in the order they were registered.
// Runs before most other callbacks on init
add_action( 'init', 'my_early_init', 1 );
// Runs after most other callbacks on init
add_action( 'init', 'my_late_init', 99 );
function my_early_init() {
// fires first
}
function my_late_init() {
// fires last
}
PHPAccepting Arguments in Actions
Some action hooks pass data to your callback. You need to declare how many arguments you want to receive using the fourth parameter of add_action():
// The save_post hook passes 3 arguments: $post_id, $post, $update
add_action( 'save_post', 'my_save_post_handler', 10, 3 );
function my_save_post_handler( int $post_id, WP_Post $post, bool $update ) {
if ( $update ) {
// Post is being updated, not created
error_log( 'Post updated: ' . $post->post_title );
}
}
PHPFilters: Modifying Data Before It’s Used
A filter hook intercepts a value, lets you modify it, and expects you to return something back. The key rule: always return a value in a filter callback. If you forget to return, the filtered value becomes null and you’ll break things silently.
// Filter: modify the excerpt length
add_filter( 'excerpt_length', 'my_custom_excerpt_length' );
function my_custom_excerpt_length( int $length ): int {
return 30; // Return 30 words instead of the default 55
}
PHPThe add_filter() function signature is identical to add_action():
add_filter( string $hook_name, callable $callback, int $priority = 10, int $accepted_args = 1 );
PHPPractical Filter Examples
// Modify post content before display
add_filter( 'the_content', 'my_append_signature' );
function my_append_signature( string $content ): string {
if ( ! is_single() ) {
return $content; // Don't modify on archive pages
}
return $content . '';
}
// Modify the login error message (hide which field is wrong for security)
add_filter( 'login_errors', 'my_generic_login_error' );
function my_generic_login_error( string $error ): string {
return 'Invalid credentials. Please try again.';
}
// Change the number of posts per page on a specific post type archive
add_filter( 'pre_get_posts', 'my_portfolio_posts_per_page' );
function my_portfolio_posts_per_page( WP_Query $query ): void {
if ( ! is_admin() && $query->is_main_query() && $query->is_post_type_archive( 'portfolio' ) ) {
$query->set( 'posts_per_page', 12 );
}
// Note: pre_get_posts is an action that receives the query by reference,
// but it's also used as a filter pattern — no return needed here
}
PHPActions vs Filters: Side-by-Side
| Actions | Filters | |
|---|---|---|
| Purpose | Run code at a point in time | Modify a value before it’s used |
| Return value | Not required (ignored) | Required — always return something |
| Function | add_action() | add_filter() |
| Triggered by | do_action() | apply_filters() |
| Common examples | init, wp_head, save_post | the_content, the_title, excerpt_length |
| Side effects | Expected (echoing, DB writes) | Should be avoided |
Removing Hooks
You can remove any action or filter that another plugin or theme registered — as long as you know the exact callback name and priority.
// Remove a named function hook
remove_action( 'wp_head', 'wp_generator' ); // Remove WP version from head
remove_filter( 'the_content', 'wpautop' ); // Remove auto paragraph wrapping
// The priority must match what was used in add_action/add_filter
// Default priority is 10 — if the original used a different priority, match it:
remove_action( 'woocommerce_before_main_content', 'woocommerce_output_content_wrapper', 10 );
PHPRemoving hooks from class methods is slightly different — you need a reference to the object:
// If a plugin registered a hook like this:
// add_action( 'init', [ $this, 'setup' ] );
// You need the object instance to remove it
global $my_plugin_instance;
remove_action( 'init', [ $my_plugin_instance, 'setup' ] );
// Anonymous functions CANNOT be removed — avoid them for public hooks
// This cannot be undone by other code:
add_action( 'init', function() {
// anonymous — irremovable
} );
PHPCreating Your Own Hooks
If you’re building a plugin for others to extend, you should add your own hooks. This is what makes plugins like WooCommerce so extensible — they fire hundreds of custom actions and filters that other developers hook into.
// In your plugin — fire a custom action
function my_plugin_process_order( int $order_id ): void {
// ... do processing ...
// Let other code run after order processing
do_action( 'my_plugin_after_order_processed', $order_id );
}
// In your plugin — apply a custom filter
function my_plugin_get_price( float $base_price, int $product_id ): float {
// Allow other code to modify the price
return apply_filters( 'my_plugin_product_price', $base_price, $product_id );
}
// Another plugin/theme can now hook into yours:
add_action( 'my_plugin_after_order_processed', function( int $order_id ) {
// Send a Slack notification, update a CRM, etc.
} );
add_filter( 'my_plugin_product_price', function( float $price, int $product_id ): float {
return $price * 0.9; // Apply 10% discount
}, 10, 2 );
PHPName your hooks with a unique prefix matching your plugin slug — myplugin_ — to avoid collisions with WordPress core or other plugins.
Using Hooks Inside Classes
Most modern plugins use OOP. Registering hooks inside a class is straightforward with [ $this, 'method_name' ]:
class My_Plugin {
public function __construct() {
add_action( 'init', [ $this, 'register_post_types' ] );
add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_assets' ] );
add_filter( 'the_content', [ $this, 'filter_content' ] );
}
public function register_post_types(): void {
// register_post_type() calls here
}
public function enqueue_assets(): void {
wp_enqueue_style( 'my-plugin', plugin_dir_url( __FILE__ ) . 'assets/style.css' );
}
public function filter_content( string $content ): string {
// modify and return
return $content;
}
}
// Instantiate once
new My_Plugin();
PHPFor static methods, use the class name string instead of $this:
add_action( 'init', [ 'My_Plugin', 'static_method' ] );
// or shorthand:
add_action( 'init', 'My_Plugin::static_method' );
PHPEssential Action Hooks Every Developer Should Know
| Hook | When it fires | Common use |
|---|---|---|
init | After WordPress loads, before headers sent | Register CPTs, taxonomies, rewrite rules |
wp_enqueue_scripts | Front-end asset loading | Enqueue CSS/JS for themes and plugins |
admin_enqueue_scripts | Admin asset loading | Enqueue admin-only CSS/JS |
wp_head | Inside tag | Add meta tags, inline styles |
wp_footer | Before closing | Add inline scripts, tracking code |
save_post | After a post is saved | Save custom meta, trigger notifications |
admin_menu | Admin menu is built | Register admin pages and subpages |
plugins_loaded | All plugins are loaded | Plugin compatibility checks |
template_redirect | Before template loads | Redirects, access control |
wp_ajax_{action} | Ajax request (logged in) | Handle admin-side AJAX |
wp_ajax_nopriv_{action} | Ajax request (public) | Handle front-end AJAX |
Essential Filter Hooks Every Developer Should Know
| Hook | Filters | Common use |
|---|---|---|
the_content | Post content before display | Append/prepend content, shortcode processing |
the_title | Post title | Add prefixes, icons, labels |
excerpt_length | Excerpt word count | Change default 55-word limit |
excerpt_more | Excerpt trailing text | Change “[…]” to a Read More link |
body_class | Array of body CSS classes | Add conditional classes |
upload_mimes | Allowed upload file types | Add SVG, custom file types |
wp_mail | Email args before sending | Change from address, add CC/BCC |
login_redirect | URL after login | Role-based login redirects |
cron_schedules | WP-Cron intervals | Add custom cron intervals |
Debugging Hooks
When hooks aren’t firing as expected, these techniques help:
// Check if a function is hooked to an action
if ( has_action( 'init', 'my_function' ) ) {
echo 'my_function is hooked to init';
}
// Get the priority it's registered at
$priority = has_filter( 'the_content', 'my_content_filter' );
// Returns false if not hooked, or the priority integer if it is
// Check what's hooked to a given action (inspect the global)
global $wp_filter;
var_dump( $wp_filter['init'] ); // Shows all callbacks and priorities
// The best approach: install Query Monitor plugin
// It shows all hooks fired on the current request, their callbacks, and priorities
PHPCommon Mistakes to Avoid
- Forgetting to return in a filter. The most common mistake. If your filter callback doesn’t return, the value becomes
nulland breaks the output silently. - Wrong priority when removing hooks.
remove_action()must use the same priority as the originaladd_action()or it won’t work. - Hooking too early. If you call
get_post()inside aninitcallback, the global query isn’t set up yet. Usewportemplate_redirectfor anything that needs query data. - Using anonymous functions for public hooks. You can’t remove an anonymous function later. Use named functions or class methods for any hook other code might need to unhook.
- Running heavy code on every hook fire.
save_postfires multiple times per save (once for the post, once for each revision). Always add a guard:if ( wp_is_post_revision( $post_id ) ) return;
Frequently Asked Questions
What is the difference between an action and a filter in WordPress?
An action runs your code at a specific point in WordPress’s execution and returns nothing. A filter intercepts a value, lets you modify it, and must return something. Use add_action() when you want to do something; use add_filter() when you want to change something.
Can I use add_filter() for action hooks?
Technically yes — WordPress uses the same internal registry for both. But you should use the semantically correct function: add_action() for actions and add_filter() for filters. It makes your code readable and is expected by other developers.
What does hook priority mean in WordPress?
Priority is an integer (default 10) that controls the order callbacks run when multiple functions are attached to the same hook. Lower numbers run first. If two callbacks share the same priority, they run in registration order. Use a higher priority (e.g. 99) to run after core and other plugins, or a lower priority (e.g. 1) to run before them.
How do I find all hooks available in WordPress?
The WordPress Developer Reference lists all core hooks. The Query Monitor plugin shows every hook fired on any given page load, including which callbacks are attached. For WooCommerce hooks specifically, the WooCommerce hooks documentation is the best reference.
When should I create my own hooks in a plugin?
Add your own hooks wherever another developer might reasonably want to extend or modify your plugin’s behaviour — before and after major operations, and around any value that should be customisable. This is what makes your plugin “developer-friendly” and is standard practice for any plugin you plan to sell or distribute.
WooCommerce stores product data using WordPress’s standard post system — products are the product post type, prices and stock levels live in post meta, and categories/tags are custom taxonomies. That means WP_Query can query products with full precision, and understanding the data structure unlocks filters you can’t get from WooCommerce’s own functions.
This guide covers everything from a basic product query to filtering by price range, stock status, attributes, sale status, and featured flag — with performance notes throughout.
Basic WooCommerce Product Query
$query = new WP_Query( [
'post_type' => 'product',
'post_status' => 'publish',
'posts_per_page' => 12,
'paged' => get_query_var( 'paged' ) ?: 1,
] );
if ( $query->have_posts() ) {
while ( $query->have_posts() ) {
$query->the_post();
$product = wc_get_product( get_the_ID() );
// use $product methods here
}
wp_reset_postdata();
}PHPInside the loop, wc_get_product() gives you the full WC_Product object with all its methods. You can then call $product->get_price(), $product->get_stock_quantity(), $product->get_image_id(), and so on — rather than reading raw meta directly.
Filter by Product Category
WooCommerce uses the product_cat taxonomy for categories and product_tag for tags:
// Single category by slug
$query = new WP_Query( [
'post_type' => 'product',
'post_status' => 'publish',
'posts_per_page' => 12,
'tax_query' => [
[
'taxonomy' => 'product_cat',
'field' => 'slug',
'terms' => 'hoodies',
],
],
] );
// Multiple categories (products in ANY of these)
$query = new WP_Query( [
'post_type' => 'product',
'post_status' => 'publish',
'posts_per_page' => 12,
'tax_query' => [
[
'taxonomy' => 'product_cat',
'field' => 'slug',
'terms' => [ 'hoodies', 'jackets', 'coats' ],
'operator' => 'IN',
],
],
] );
// Products in BOTH categories (AND logic)
$query = new WP_Query( [
'post_type' => 'product',
'post_status' => 'publish',
'posts_per_page' => 12,
'tax_query' => [
'relation' => 'AND',
[
'taxonomy' => 'product_cat',
'field' => 'slug',
'terms' => 'mens',
],
[
'taxonomy' => 'product_cat',
'field' => 'slug',
'terms' => 'sale',
],
],
] );PHPFilter by Price Range
Regular price and sale price are stored in post meta as _regular_price and _sale_price. The current active price is _price. Always filter on _price — it reflects whatever WooCommerce considers the current applicable price including sale rules:
// Products between $20 and $100
$query = new WP_Query( [
'post_type' => 'product',
'post_status' => 'publish',
'posts_per_page' => 12,
'meta_query' => [
[
'key' => '_price',
'value' => [ 20, 100 ],
'compare' => 'BETWEEN',
'type' => 'NUMERIC', // critical — without this, '9' > '100' as a string
],
],
'orderby' => 'meta_value_num',
'meta_key' => '_price',
'order' => 'ASC',
] );
// Products under $50
$query = new WP_Query( [
'post_type' => 'product',
'post_status' => 'publish',
'meta_query' => [
[
'key' => '_price',
'value' => 50,
'compare' => '<=',
'type' => 'NUMERIC',
],
],
] );PHPImportant: Always set 'type' => 'NUMERIC' on price queries. Without it, MySQL compares values as strings. “9.99” would be treated as greater than “100.00” because “9” > “1” in string comparison — a silent bug that returns completely wrong results.
Filter by Stock Status
Stock status is stored in the _stock_status meta key with values instock, outofstock, or onbackorder:
// In-stock products only
$query = new WP_Query( [
'post_type' => 'product',
'post_status' => 'publish',
'posts_per_page' => 12,
'meta_query' => [
[
'key' => '_stock_status',
'value' => 'instock',
],
],
] );
// Out of stock products (useful for admin reporting)
$query = new WP_Query( [
'post_type' => 'product',
'post_status' => 'publish',
'meta_query' => [
[
'key' => '_stock_status',
'value' => 'outofstock',
],
],
] );PHPFilter On-Sale Products
WooCommerce doesn’t store a simple “is on sale” flag. A product is on sale when its _sale_price is set and non-empty, and the current date falls within any scheduled sale dates (or no dates are set). The most reliable approach uses WooCommerce’s own helper to get the IDs first:
// Method 1: Use WooCommerce's built-in sale IDs (recommended)
$on_sale_ids = wc_get_product_ids_on_sale(); // returns cached array of IDs
if ( ! empty( $on_sale_ids ) ) {
$query = new WP_Query( [
'post_type' => 'product',
'post_status' => 'publish',
'posts_per_page' => 12,
'post__in' => $on_sale_ids,
'orderby' => 'post__in', // preserve the order
] );
}
// Method 2: Meta query (less reliable for scheduled sales)
$query = new WP_Query( [
'post_type' => 'product',
'post_status' => 'publish',
'meta_query' => [
'relation' => 'AND',
[
'key' => '_sale_price',
'value' => '',
'compare' => '!=',
],
[
'key' => '_sale_price',
'compare' => 'EXISTS',
],
],
] );PHPMethod 1 is preferred because WooCommerce caches the sale IDs and handles scheduled sale dates correctly. Method 2 may include products where a sale price exists in the database but the sale period has ended.
Filter Featured Products
In WooCommerce 3.0+, “featured” is stored as a product taxonomy term in product_visibility:
// WooCommerce 3.0+ — featured uses product_visibility taxonomy
$query = new WP_Query( [
'post_type' => 'product',
'post_status' => 'publish',
'posts_per_page' => 8,
'tax_query' => [
[
'taxonomy' => 'product_visibility',
'field' => 'name',
'terms' => 'featured',
],
],
] );PHPPre-WooCommerce 3.0 used a _featured meta key with value 'yes'. If you’re on a legacy store, replace the tax_query with 'meta_key' => '_featured', 'meta_value' => 'yes' — but upgrade if at all possible, as that version is years out of support.
Filter by Product Attribute
Product attributes (like Color, Size) are stored as custom taxonomies prefixed with pa_:
// Products with Color = Red
$query = new WP_Query( [
'post_type' => 'product',
'post_status' => 'publish',
'posts_per_page' => 12,
'tax_query' => [
[
'taxonomy' => 'pa_color', // pa_ prefix + attribute slug
'field' => 'slug',
'terms' => 'red',
],
],
] );
// Multiple attributes — Red OR Blue, AND Size = Large
$query = new WP_Query( [
'post_type' => 'product',
'post_status' => 'publish',
'posts_per_page' => 12,
'tax_query' => [
'relation' => 'AND',
[
'taxonomy' => 'pa_color',
'field' => 'slug',
'terms' => [ 'red', 'blue' ],
'operator' => 'IN',
],
[
'taxonomy' => 'pa_size',
'field' => 'slug',
'terms' => 'large',
],
],
] );PHPCombining Multiple Filters
Here’s a real-world query combining category, price range, stock status, and ordering — the kind you’d build for a filtered shop page:
$query = new WP_Query( [
'post_type' => 'product',
'post_status' => 'publish',
'posts_per_page' => 24,
'paged' => get_query_var( 'paged' ) ?: 1,
'tax_query' => [
[
'taxonomy' => 'product_cat',
'field' => 'slug',
'terms' => 'shirts',
],
],
'meta_query' => [
'relation' => 'AND',
'price_clause' => [
'key' => '_price',
'value' => [ 10, 80 ],
'compare' => 'BETWEEN',
'type' => 'NUMERIC',
],
[
'key' => '_stock_status',
'value' => 'instock',
],
],
'orderby' => 'price_clause', // named meta clause for orderby
'order' => 'ASC',
] );
// Always check found_posts for pagination
$total_pages = $query->max_num_pages;PHPUsing WC_Product_Query Instead
WooCommerce ships its own WC_Product_Query class that wraps WP_Query with product-specific arguments. It’s worth knowing about for simpler use cases:
$products = wc_get_products( [
'status' => 'publish',
'limit' => 12,
'category' => [ 'shirts' ], // category slugs — no tax_query needed
'stock_status' => 'instock',
'orderby' => 'price',
'order' => 'ASC',
] );
// Returns array of WC_Product objects directly — no loop needed
foreach ( $products as $product ) {
echo $product->get_name() . ': ' . $product->get_price();
}PHPUse wc_get_products() when you want product objects returned directly without a template loop. Use WP_Query directly when you need the_post(), pagination via max_num_pages, or complex combined meta_query / tax_query logic that WC_Product_Query doesn’t expose.
Performance Tips for WooCommerce Queries
- Prefer
tax_queryovermeta_queryfor categorisation. Filtering byproduct_catorpa_colorhits indexed taxonomy tables. Filtering by_pricehitswp_postmeta, which has limited indexing. - Add a database index on
_priceif you run price-range queries on a large catalogue. This is a single ALTER TABLE statement onwp_postmetaand can reduce query time dramatically. - Cache sale IDs.
wc_get_product_ids_on_sale()is already cached as a transient, but if you call it in a custom query, wrap the whole query in a transient with a 12-hour expiry. - Use
fields => 'ids'when you only need to count products or pass IDs to another function — avoids loading full post objects. - Set
no_found_rows => trueon non-paginated product blocks and widgets.
Frequently Asked Questions
How do I query WooCommerce products by price range?
Use a meta_query on the _price key with 'compare' => 'BETWEEN' and 'type' => 'NUMERIC'. Always use _price rather than _regular_price — it reflects the current active price including any active sales.
What meta keys does WooCommerce use?
Key meta fields: _price (current price), _regular_price, _sale_price, _stock_status (instock/outofstock/onbackorder), _stock (quantity number), _sku, _weight, _virtual (yes/no), _downloadable (yes/no), _manage_stock (yes/no).
How do I get WooCommerce product attributes in a query?
Attributes created via WooCommerce → Attributes are stored as custom taxonomies with a pa_ prefix. The slug “Color” becomes the taxonomy pa_color. Use this in a tax_query just like any other taxonomy.
Why does my WooCommerce WP_Query return no results?
Common causes: wrong post type (use 'product', not 'products'), missing 'post_status' => 'publish', a meta_query type mismatch (numeric vs string), or a taxonomy slug that doesn’t exist. Add var_dump( $query->request ) to see the raw SQL, or use the Query Monitor plugin to inspect queries on the page.
Should I use WP_Query or wc_get_products() for WooCommerce?
Use wc_get_products() when you want a flat array of WC_Product objects for data processing. Use WP_Query when you need the full template loop, pagination, or a complex combination of meta_query and tax_query that wc_get_products() doesn’t support. Both ultimately hit the same database tables.
What Is WP_Query?
WP_Query is WordPress’s built-in class for querying the database and retrieving posts. Every page you visit on a WordPress site runs a query behind the scenes — WP_Query is what powers it. The difference is that you can instantiate your own version anywhere: in templates, shortcodes, widgets, REST API callbacks, and plugin code.
At its simplest, it looks like this:
$query = new WP_Query( [
'post_type' => 'post',
'posts_per_page' => 10,
] );
if ( $query->have_posts() ) {
while ( $query->have_posts() ) {
$query->the_post();
// your template code here
}
wp_reset_postdata();
}PHPYou pass an array of arguments, call have_posts() to loop, and always end with wp_reset_postdata() to restore the global $post object. That last step is easy to forget and causes subtle bugs — keep it habitual.
WP_Query vs get_posts() vs query_posts()
Before diving into arguments, it helps to know when to use WP_Query over its alternatives:
| Function | Returns | Use when | Avoid when |
|---|---|---|---|
WP_Query | Object with full loop methods | Custom loops in templates or plugins | You just need a flat array of posts |
get_posts() | Array of WP_Post objects | Simple lists, widget data, REST responses | You need pagination or loop methods |
query_posts() | Modifies the main query globally | Never — use pre_get_posts instead | Always |
get_posts() actually wraps WP_Query internally. The practical difference: get_posts() sets suppress_filters => true by default, which means plugins that hook into query filters (like WPML or SEO plugins) are bypassed. Use WP_Query when filters matter; use get_posts() when you want raw speed and a simple array back.
Post Type & Status Arguments
$query = new WP_Query( [
'post_type' => 'product', // string or array: [ 'post', 'page', 'product' ]
'post_status' => 'publish', // 'publish' | 'draft' | 'private' | 'any' | array
] );PHPCommon gotcha: querying 'post_status' => 'any' includes trashed posts. If you’re building an admin screen that should exclude trash, use [ 'publish', 'draft', 'pending', 'private' ] explicitly.
Pagination Arguments
$query = new WP_Query( [
'post_type' => 'post',
'posts_per_page' => 12, // -1 to get all (avoid on large datasets)
'paged' => get_query_var( 'paged' ) ?: 1,
'offset' => 0, // skips N posts — mutually exclusive with 'paged'
'no_found_rows' => true, // skips COUNT() query — use when you don't need pagination
] );PHPPerformance note: no_found_rows => true is one of the most impactful flags you can set. By default WP_Query runs an extra SQL_CALC_FOUND_ROWS query to count total results for pagination. If you’re rendering a widget or sidebar list where you don’t need page numbers, this flag eliminates that query entirely.
Taxonomy Arguments
Simple single-taxonomy query:
$query = new WP_Query( [
'post_type' => 'post',
'posts_per_page' => 10,
'category_name' => 'tutorials', // slug, not ID
] );PHPFor custom taxonomies or complex conditions, use tax_query:
$query = new WP_Query( [
'post_type' => 'product',
'posts_per_page' => 12,
'tax_query' => [
'relation' => 'AND',
[
'taxonomy' => 'product_cat',
'field' => 'slug',
'terms' => [ 'shirts', 'hoodies' ],
'operator' => 'IN', // IN | NOT IN | AND | EXISTS | NOT EXISTS
],
[
'taxonomy' => 'product_tag',
'field' => 'slug',
'terms' => 'sale',
'operator' => 'IN',
],
],
] );PHPThe field argument accepts 'slug', 'name', or 'term_id'. Use 'term_id' when you have the ID — it skips an extra lookup and is slightly faster. Use 'slug' in hardcoded queries so the query still works if term IDs change across environments.
Meta Query Arguments
Meta queries let you filter by custom field values. They’re powerful but expensive — always index the meta key if you’re querying it frequently.
$query = new WP_Query( [
'post_type' => 'event',
'posts_per_page' => 10,
'meta_query' => [
'relation' => 'AND',
'start_date_clause' => [ // named clause — lets you use it in orderby
'key' => 'event_start_date',
'value' => date( 'Ymd' ),
'compare' => '>=',
'type' => 'DATE',
],
[
'key' => 'event_status',
'value' => 'cancelled',
'compare' => '!=',
],
],
'orderby' => 'start_date_clause', // order by the named clause
'order' => 'ASC',
] );PHPAvailable compare operators: =, !=, >, >=, <, <=, LIKE, NOT LIKE, IN, NOT IN, BETWEEN, NOT BETWEEN, EXISTS, NOT EXISTS, REGEXP.
Available type values: NUMERIC, BINARY, CHAR, DATE, DATETIME, DECIMAL, SIGNED, TIME, UNSIGNED. Always set the correct type — without it, numeric comparisons like >= fall back to string comparison and produce wrong results (e.g. “9” > “10” as strings).
Orderby Arguments
// Single orderby
$query = new WP_Query( [
'post_type' => 'post',
'orderby' => 'date',
'order' => 'DESC', // ASC | DESC
] );
// Multiple orderby (WordPress 4.0+)
$query = new WP_Query( [
'post_type' => 'product',
'meta_key' => 'price', // required when orderby is 'meta_value_num'
'orderby' => [
'meta_value_num' => 'ASC', // price low to high
'title' => 'ASC', // then alphabetically
],
] );PHPCommon orderby values: date, modified, title, name (slug), ID, rand, menu_order, meta_value, meta_value_num, comment_count, relevance (search only), post__in (preserves order of a hardcoded ID array).
Date Arguments
// Posts from a specific month
$query = new WP_Query( [
'post_type' => 'post',
'date_query' => [
[
'year' => 2026,
'month' => 1,
],
],
] );
// Posts published in the last 30 days
$query = new WP_Query( [
'post_type' => 'post',
'date_query' => [
[
'after' => '30 days ago',
'inclusive' => true,
],
],
] );PHPSearch Arguments
$query = new WP_Query( [
'post_type' => 'post',
's' => sanitize_text_field( $search_term ), // always sanitize user input
] );PHPWordPress’s built-in search only checks post title and content. If you need to search custom fields or taxonomy terms, look at extending the query via filters or use a dedicated search plugin like SearchWP.
Author & User Arguments
$query = new WP_Query( [
'post_type' => 'post',
'author' => 5, // single user ID
'author__in' => [ 3, 5, 12 ], // multiple user IDs
'author__not_in' => [ 1 ], // exclude admin
'author_name' => 'kamal', // by user_login slug
] );PHPPerformance Best Practices
WP_Query is fast when used correctly and slow when abused. Here are the habits that matter most:
- Always use
no_found_rows => truein non-paginated queries. This eliminates theCOUNT(*)SQL call. - Limit
posts_per_page. Never use-1on a post type with thousands of entries. Paginate instead. - Avoid
meta_queryon unindexed keys. Add a database index towp_postmeta.meta_valuefor keys you filter on frequently, or store data in a dedicated table instead. - Use
fields => 'ids'when you only need post IDs — this avoids loading fullWP_Postobjects and their metadata. - Cache results with
wp_cache_set()/wp_cache_get()for queries that run on every page load. - Prefer
tax_queryovermeta_queryfor categorisation. Taxonomy tables are indexed and purpose-built for filtering; meta tables are not.
// Lightweight ID-only query
$query = new WP_Query( [
'post_type' => 'post',
'posts_per_page' => 100,
'fields' => 'ids', // returns array of integers only
'no_found_rows' => true,
'update_post_term_cache' => false, // skip loading term cache
'update_post_meta_cache' => false, // skip loading meta cache
] );PHPModifying the Main Query with pre_get_posts
For archive pages, category pages, and search results, you should modify the main query rather than creating a secondary one. Do this in functions.php or a plugin using the pre_get_posts action:
add_action( 'pre_get_posts', function( WP_Query $query ) {
// Only modify the main query on the front end, not in the admin
if ( is_admin() || ! $query->is_main_query() ) {
return;
}
// Show 24 products per page on shop/category archives
if ( $query->is_post_type_archive( 'product' ) || $query->is_tax( 'product_cat' ) ) {
$query->set( 'posts_per_page', 24 );
}
// Exclude a specific category from the blog index
if ( $query->is_home() ) {
$query->set( 'category__not_in', [ 5 ] );
}
} );PHPThis is more efficient than creating a secondary WP_Query on archive templates because WordPress only runs one database query instead of two.
Full Reference: All WP_Query Arguments
Here’s a condensed reference of every argument group:
| Group | Key arguments |
|---|---|
| Post type / status | post_type, post_status, post__in, post__not_in, post_name__in |
| Pagination | posts_per_page, paged, offset, no_found_rows, nopaging |
| Order | orderby, order, meta_key (for meta_value orderby) |
| Author | author, author__in, author__not_in, author_name |
| Category / tag | cat, category_name, tag, tag_id, tag__in |
| Taxonomy | tax_query (nested array) |
| Meta / custom fields | meta_query, meta_key, meta_value, meta_compare, meta_type |
| Date | date_query, year, month, day, before, after |
| Search | s, exact, sentence |
| Permission | perm (readable | editable) |
| Return format | fields (all | ids | id=>parent) |
| Cache | update_post_term_cache, update_post_meta_cache, cache_results |
Frequently Asked Questions
What is the difference between WP_Query and get_posts?
get_posts() is a wrapper around WP_Query that returns a flat array of post objects. It disables filters by default (suppress_filters => true) and is slightly simpler for basic use. WP_Query gives you the full loop API, pagination data, and respects all query filters. Use get_posts() for simple lists; use WP_Query when you need pagination, the loop, or filter compatibility.
Why should I avoid query_posts()?
query_posts() replaces the main query entirely and breaks pagination, conditional tags, and plugin integrations. Use pre_get_posts to modify the main query, and WP_Query for secondary queries.
Do I always need wp_reset_postdata()?
Yes, whenever you call $query->the_post() inside your loop. This function sets the global $post variable to the current post. Without wp_reset_postdata() after your loop, the global $post stays as the last post from your custom query, which breaks any subsequent template code that relies on it.
How do I debug a WP_Query that returns no results?
Add var_dump( $query->request ) after instantiating the query to see the raw SQL. You can also check $query->found_posts and $query->post_count. The Query Monitor plugin is invaluable here — it shows every query run on the page with its arguments and execution time.
If you’ve ever wanted to connect your WordPress site with an external app or service, creating a custom REST API endpoint is the best way to do it. As a developer, I often use WordPress REST APIs to send or receive data from other systems like mobile apps, SaaS platforms, or custom dashboards.
In this post, I’ll walk you through how to build a custom REST API endpoint in WordPress, step by step.
What is REST API in WordPress
WordPress comes with a built-in REST API that allows developers to interact with site data using simple HTTP requests (GET, POST, PUT, DELETE). For example, you can fetch posts or create users without logging into wp-admin.
But sometimes, you need custom endpoints maybe to integrate with your mobile app or to send data to a third-party CRM. That’s where creating your own endpoint helps.
Step 1: Register the Custom Endpoint
To create a REST API endpoint, we use the register_rest_route() function inside the rest_api_init hook.
Let’s say we want to create an endpoint to fetch user details.
add_action('rest_api_init', function () {
register_rest_route('custom/v1', '/user-info', [
'methods' => 'GET',
'callback' => 'get_custom_user_info',
'permission_callback' => '__return_true'
]);
});
function get_custom_user_info(WP_REST_Request $request) {
$user_id = get_current_user_id();
if (!$user_id) {
return new WP_Error('no_user', 'User not logged in', ['status' => 401]);
}
$user = get_userdata($user_id);
return [
'id' => $user->ID,
'name' => $user->display_name,
'email' => $user->user_email,
];
}
PHPNow you can access your endpoint from:https://yourdomain.com/wp-json/custom/v1/user-info
Step 2: Adding POST Method (For Sending Data)
If you want to receive data (for example, from a mobile app), use the POST method.
Let’s create an endpoint to save feedback data.
add_action('rest_api_init', function () {
register_rest_route('custom/v1', '/feedback', [
'methods' => 'POST',
'callback' => 'save_user_feedback',
'permission_callback' => '__return_true'
]);
});
function save_user_feedback(WP_REST_Request $request) {
$data = $request->get_json_params();
$feedback = sanitize_text_field($data['message']);
$user_id = get_current_user_id();
wp_insert_post([
'post_type' => 'feedback',
'post_title' => 'Feedback from ' . $user_id,
'post_content'=> $feedback,
'post_status' => 'publish'
]);
return ['success' => true, 'message' => 'Feedback received successfully!'];
}
PHPNow your external app can send POST requests to:https://yourdomain.com/wp-json/custom/v1/feedback
This will save the feedback data directly to WordPress.
Step 3: Adding Authentication
You shouldn’t make your endpoints public unless you must.
For secure endpoints, use the WordPress REST API authentication methods like:
- Basic Authentication (for testing)
- Application Passwords (for secure access)
- OAuth (for large-scale integrations)
For example, using Application Passwords, your external app can send requests safely with a username and generated password.
Step 4: Test Your Endpoint
To test your endpoint, you can use:
Example GET request in Postman:
GET https://yourdomain.com/wp-json/custom/v1/user-info
BashYou’ll see a JSON response like this:
{
"id": 2,
"name": "Kamal Hosen",
"email": "[email protected]"
}
JSONStep 5: Use Your Endpoint in an External App
Now that your endpoint works, you can connect it with any system.
For example:
- Send feedback from your mobile app to WordPress
- Fetch user data for a custom dashboard
- Connect your WooCommerce store data to another system
WordPress REST API is flexible you can integrate almost anything once you understand the basics.
Final Thoughts
Building a custom REST API endpoint in WordPress gives you complete control over how data moves between systems. Whether you’re connecting a mobile app, syncing a CRM, or building a headless WordPress setup custom endpoints make your life easier.
If you’re a WordPress developer looking to expand beyond themes and plugins, this is a great step forward.
If you’ve worked with WordPress for a while, you’ve probably used both get_posts() and WP_Query to retrieve posts. They seem similar, right? But there are subtle differences that can affect your site’s performance and flexibility. Let’s break down when to use each with examples you can actually use in your next project.
Understanding WP_Query
WP_Query is the foundation of how WordPress fetches posts from the database. It’s a powerful class that gives you full control over the query process.
You can filter posts by category, author, meta fields, date, custom taxonomy, or just about anything. This is what WordPress itself uses to build archive pages, search results, and the main blog loop.
Here’s a simple example:
$args = [
'post_type' => 'post',
'posts_per_page' => 5,
'category_name' => 'wordpress-tips'
];
$query = new WP_Query($args);
if ($query->have_posts()) {
while ($query->have_posts()) {
$query->the_post();
echo '' . get_the_title() . '';
}
wp_reset_postdata();
}
PHPThis gives you a custom query loop. You can place it anywhere — a template, a custom page, or even inside a widget.
Use WP_Query when:
- You need a custom loop with full flexibility.
- You want pagination (since
get_posts()doesn’t handle it). - You need to work with complex meta queries or multiple taxonomies.
- You’re building templates like blog listings, portfolios, or product grids.
Basically, if you need control, WP_Query is the right choice.
Understanding get_posts()
Now, get_posts() is a simplified wrapper around WP_Query.
It runs the same underlying class but with some defaults that make it lightweight and easy to use.
Here’s how you might use it:
$args = [
'numberposts' => 5,
'post_type' => 'post',
'orderby' => 'date',
'order' => 'DESC'
];
$recent_posts = get_posts($args);
foreach ($recent_posts as $post) {
setup_postdata($post);
echo '' . get_the_title() . '';
}
wp_reset_postdata();
PHPThe main difference here is that get_posts() returns an array of post objects — it doesn’t handle pagination or global query variables.
Use get_posts() when:
- You need a quick list of posts (like related posts or sidebar widgets).
- You don’t need pagination.
- You want simplicity and performance.
- You’re writing a small utility or function that fetches posts quietly in the background.
In short, get_posts() is great when you want a lightweight, read-only post fetch without extra overhead.
Performance Considerations
When performance matters, get_posts() is often faster because it doesn’t load extra query features like pagination or conditional tags.
However, if you need total control over your query, such as ordering by meta value or filtering by multiple custom fields, go with WP_Query.
Remember: get_posts() internally calls WP_Query, but with 'suppress_filters' => true by default — meaning filters like pre_get_posts won’t run.
So, if you’re expecting filters or hooks to modify your query, use WP_Query directly.
Quick Comparison
| Feature | get_posts() | WP_Query |
|---|---|---|
| Pagination | ❌ No | ✅ Yes |
| Filters Applied | ❌ Suppressed | ✅ Runs normally |
| Performance | ⚡ Faster for simple queries | 🧠 More flexible but heavier |
| Return Type | Array of post objects | Full WP_Query object |
| Best Use Case | Small custom fetch | Custom loops and templates |
Final Thoughts
Both get_posts() and WP_Query are powerful in their own ways.
If you just need a handful of posts, use get_posts() — it’s clean, fast, and easy.
If you’re building a full custom loop or archive, go for WP_Query — it gives you all the tools and hooks WordPress offers.
In the end, it’s not about which one is better, but which one fits the job better.
