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.
Bash

That 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.
Bash

Claude 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.
Bash

The 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
    ] );
}
PHP

The 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

ArgumentWhat it doesDefault
publicMakes the CPT visible in the admin and on the front endfalse
has_archiveEnables an archive page at /events/false
rewriteControls the URL slug for single postspost type key
supportsWhich editor fields to show (title, editor, thumbnail, etc.)['title','editor']
show_in_restExposes the CPT via REST API and enables Gutenbergfalse
menu_iconDashicon slug or SVG data URI for the admin menugeneric post icon
capability_typeBase for permission checks ('post' reuses post caps)'post'
hierarchicalSet true for page-like parent/child structurefalse

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
} );
PHP

Always 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
    ] );
}
PHP

You 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();
} );
PHP

Never 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,
] );
PHP

To 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();
} );
PHP

For 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

cd wp-content/plugins
npx @wordpress/create-block my-first-block --namespace myfirstblock
Bash

This 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)
Bash

Activate the plugin in WordPress, then start the dev server:

cd my-first-block
npm install
npm start
Bash

From 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"
}
JSON

A 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>
		
	);
}
JavaScript

useBlockProps() 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="" }="">
		
); }JavaScript

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' );
} );
PHP

That’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 build
Bash

Always 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;
}
JavaScript
add_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 ); }
PHP

Always 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

PackageWhat it gives you
@wordpress/block-editoruseBlockProps, RichText, InspectorControls, BlockControls, colour/alignment tools
@wordpress/componentsUI components: PanelBody, TextControl, ToggleControl, SelectControl, Button
@wordpress/dataAccess to the editor data store — current post, editor settings, selected block
@wordpress/i18n__( 'string', 'textdomain' ) for translations
@wordpress/api-fetchFetch data from the WordPress REST API with nonce auth built in
@wordpress/hooksJavaScript version of WordPress actions and filtersaddFilter, 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,
        ],
    ] );
} );
PHP

How 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
}
PHP

manage_options is the capability tied to the Administrator role. Here are the ones you’ll use most often:

CapabilityWho has itUse when
manage_optionsAdministratorPlugin settings, site-wide config
edit_postsEditor, Author, ContributorCreating or editing content
publish_postsEditor, AuthorPublishing content
edit_others_postsEditorEditing other users’ content
manage_categoriesEditor, AdministratorCategory/taxonomy management
upload_filesAuthor and aboveMedia 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
} );
PHP

Nonces: 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

// Step 1: Output the nonce field in your form
function my_plugin_settings_form(): void {
    ?>
    <form method="post" action="options.php">
        
        
        <input type="submit" value="Save Settings">
    form>
    
        
>
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 ),
    ] );
}
PHP

This 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

TaskFunction
Check user permissioncurrent_user_can( $capability )
Add nonce to formwp_nonce_field( $action, $name )
Verify form noncewp_verify_nonce( $_POST[$name], $action )
Create nonce for JSwp_create_nonce( $action )
Verify AJAX noncecheck_ajax_referer( $action, $query_arg, false )
Sanitize plain textsanitize_text_field()
Sanitize textareasanitize_textarea_field()
Sanitize emailsanitize_email()
Sanitize URL for storageesc_url_raw()
Sanitize integerabsint()
Sanitize HTML contentwp_kses_post()
Escape for HTML outputesc_html()
Escape for attributeesc_attr()
Escape URL for outputesc_url()
Escape for JavaScriptesc_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 );
    }
} );
PHP

Those 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;
    }
} );
PHP

Each 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 ] );
    }
} );
PHP

You 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 ] );
    }
} );
PHP

Including 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' ] );
    }
} );
PHP

Pass 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',
            ],
        ] );
    }
} );
PHP

Changing 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' );
    }
} );
PHP

You 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' );
    }
} );
PHP

Using 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();
PHP

What 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_posts fires for that too — but your is_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_posts to make it behave like a tag archive. The conditional tags like is_category() are already set by the time this hook fires.
  • It’s not the right place for redirects. Use template_redirect for 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:

  1. Check that is_admin() and is_main_query() guards are in place and aren’t filtering out the request you’re targeting.
  2. Confirm the conditional tag is correct. is_home() is the blog index, not the front page — if you have a static front page set, use is_front_page() for that.
  3. Add a quick var_dump( $query->query_vars ) just before your return to confirm the modified values are set.
  4. 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 '';
}
PHP

The add_action() function signature:

add_action( string $hook_name, callable $callback, int $priority = 10, int $accepted_args = 1 );
PHP

Priority 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
}
PHP

Accepting 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 );
    }
}
PHP

Filters: 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
}
PHP

The add_filter() function signature is identical to add_action():

add_filter( string $hook_name, callable $callback, int $priority = 10, int $accepted_args = 1 );
PHP

Practical 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 . '

Written by Kamal Hosen

';
} // 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 }
PHP

Actions vs Filters: Side-by-Side

ActionsFilters
PurposeRun code at a point in timeModify a value before it’s used
Return valueNot required (ignored)Required — always return something
Functionadd_action()add_filter()
Triggered bydo_action()apply_filters()
Common examplesinit, wp_head, save_postthe_content, the_title, excerpt_length
Side effectsExpected (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 );
PHP

Removing 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
} );
PHP

Creating 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 );
PHP

Name 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();
PHP

For 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' );
PHP

Essential Action Hooks Every Developer Should Know

HookWhen it firesCommon use
initAfter WordPress loads, before headers sentRegister CPTs, taxonomies, rewrite rules
wp_enqueue_scriptsFront-end asset loadingEnqueue CSS/JS for themes and plugins
admin_enqueue_scriptsAdmin asset loadingEnqueue admin-only CSS/JS
wp_headInside tagAdd meta tags, inline styles
wp_footerBefore closing Add inline scripts, tracking code
save_postAfter a post is savedSave custom meta, trigger notifications
admin_menuAdmin menu is builtRegister admin pages and subpages
plugins_loadedAll plugins are loadedPlugin compatibility checks
template_redirectBefore template loadsRedirects, 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

HookFiltersCommon use
the_contentPost content before displayAppend/prepend content, shortcode processing
the_titlePost titleAdd prefixes, icons, labels
excerpt_lengthExcerpt word countChange default 55-word limit
excerpt_moreExcerpt trailing textChange “[…]” to a Read More link
body_classArray of body CSS classesAdd conditional classes
upload_mimesAllowed upload file typesAdd SVG, custom file types
wp_mailEmail args before sendingChange from address, add CC/BCC
login_redirectURL after loginRole-based login redirects
cron_schedulesWP-Cron intervalsAdd 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
PHP

Common Mistakes to Avoid

  • Forgetting to return in a filter. The most common mistake. If your filter callback doesn’t return, the value becomes null and breaks the output silently.
  • Wrong priority when removing hooks. remove_action() must use the same priority as the original add_action() or it won’t work.
  • Hooking too early. If you call get_post() inside an init callback, the global query isn’t set up yet. Use wp or template_redirect for 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_post fires 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();
}
PHP

Inside 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',
        ],
    ],
] );
PHP

Filter 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',
        ],
    ],
] );
PHP

Important: 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',
        ],
    ],
] );
PHP

Filter 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',
        ],
    ],
] );
PHP

Method 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',
        ],
    ],
] );
PHP

Pre-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',
        ],
    ],
] );
PHP

Combining 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;
PHP

Using 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();
}
PHP

Use 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_query over meta_query for categorisation. Filtering by product_cat or pa_color hits indexed taxonomy tables. Filtering by _price hits wp_postmeta, which has limited indexing.
  • Add a database index on _price if you run price-range queries on a large catalogue. This is a single ALTER TABLE statement on wp_postmeta and 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 => true on 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();
}
PHP

You 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:

FunctionReturnsUse whenAvoid when
WP_QueryObject with full loop methodsCustom loops in templates or pluginsYou just need a flat array of posts
get_posts()Array of WP_Post objectsSimple lists, widget data, REST responsesYou need pagination or loop methods
query_posts()Modifies the main query globallyNever — use pre_get_posts insteadAlways

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
] );
PHP

Common 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
] );
PHP

Performance 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
] );
PHP

For 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',
        ],
    ],
] );
PHP

The 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',
] );
PHP

Available 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
    ],
] );
PHP

Common 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,
        ],
    ],
] );
PHP

Search Arguments

$query = new WP_Query( [
    'post_type' => 'post',
    's'         => sanitize_text_field( $search_term ),   // always sanitize user input
] );
PHP

WordPress’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
] );
PHP

Performance 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 => true in non-paginated queries. This eliminates the COUNT(*) SQL call.
  • Limit posts_per_page. Never use -1 on a post type with thousands of entries. Paginate instead.
  • Avoid meta_query on unindexed keys. Add a database index to wp_postmeta.meta_value for 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 full WP_Post objects and their metadata.
  • Cache results with wp_cache_set() / wp_cache_get() for queries that run on every page load.
  • Prefer tax_query over meta_query for 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
] );
PHP

Modifying 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 ] );
    }
} );
PHP

This 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:

GroupKey arguments
Post type / statuspost_type, post_status, post__in, post__not_in, post_name__in
Paginationposts_per_page, paged, offset, no_found_rows, nopaging
Orderorderby, order, meta_key (for meta_value orderby)
Authorauthor, author__in, author__not_in, author_name
Category / tagcat, category_name, tag, tag_id, tag__in
Taxonomytax_query (nested array)
Meta / custom fieldsmeta_query, meta_key, meta_value, meta_compare, meta_type
Datedate_query, year, month, day, before, after
Searchs, exact, sentence
Permissionperm (readable | editable)
Return formatfields (all | ids | id=>parent)
Cacheupdate_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,
    ];
}
PHP

Now 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!'];
}
PHP

Now 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
Bash

You’ll see a JSON response like this:

{
  "id": 2,
  "name": "Kamal Hosen",
  "email": "[email protected]"
}
JSON

Step 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(); }
PHP

This 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();
PHP

The 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

Featureget_posts()WP_Query
Pagination❌ No✅ Yes
Filters Applied❌ Suppressed✅ Runs normally
Performance⚡ Faster for simple queries🧠 More flexible but heavier
Return TypeArray of post objectsFull WP_Query object
Best Use CaseSmall custom fetchCustom 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.