Tutorials on Linux, WordPress and web APIs

Category: WordPress (page 1 of 1)

Debugging WordPress Performance: A Query Monitor Walkthrough

WordPress performance issues are annoying to diagnose because the symptoms – slow page loads, high server CPU – don’t tell you much about the cause. Is it a slow database query? A plugin making HTTP calls on every page load? PHP taking forever to render a template? Without the right tool, you’re guessing.

Query Monitor is my first stop whenever I’m investigating a slow WordPress site. It’s a free plugin that adds a debug bar to the admin toolbar with detailed breakdowns of everything that happened during the request. Here’s how I use it.

Installation and setup

Install from the WordPress plugin directory like any other plugin. Activate it and you’ll see a new toolbar item showing the page’s query count and total time. Click it and a panel opens at the bottom of the screen.

By default, Query Monitor only shows output to users who are logged in as admins. You can set a cookie to enable it for a specific session without being logged in, which is useful when you want to profile the front-end experience of a non-admin user. There’s a section in the plugin settings that explains how to set that cookie.

One thing to know before you start: Query Monitor adds some overhead itself. The numbers you see are slightly higher than what a real visitor experiences. Use it to identify relative problems – a query taking 200ms is a problem whether the baseline is 50ms overhead or not.

Reading the Queries panel

This is where most performance investigations start. The Queries panel lists every database query that ran during the request, with the SQL, duration, caller (which function called it), and component (which plugin or theme).

What to look for:

  • Duplicate queries. Query Monitor highlights these in orange. Seeing the same query run 30 times usually means something is fetching post data in a loop without caching results. Classic N+1 problem – for each post in a list, code is running a separate query to fetch related data instead of loading everything in one query upfront.
  • High duration queries. Sort by duration. Anything above 50ms is worth investigating. Look at the SQL – is there a WHERE clause without an index? Are you querying on post meta keys that aren’t indexed?
  • Unexpected callers. If a query shows up with a caller from a plugin you haven’t thought about in months, that’s a flag. Some plugins run heavy queries on every page load unconditionally.

The Queries by Component breakdown is useful for blame assignment – it groups query counts and total time by plugin or theme. I’ve found plugins responsible for 80% of queries on sites that were visibly slow.

The Hooks panel

If you’ve read up on how hooks work in WordPress, you’ll appreciate this panel. It shows every action and filter that fired during the request, how many callbacks were attached, and the total time spent in those callbacks.

This is where you find hooks doing expensive work on every request. I once found a plugin that had attached a callback to the_content filter that was running a remote HTTP request to fetch translation data – on every single page load, for every post. The Hooks panel made it immediately visible: one filter callback, 800ms duration.

You can also use the Hooks panel to understand execution order when you’re writing your own hooks. It lists priorities alongside callback names, so you can see exactly where your code is firing relative to everything else.

HTTP API Calls

WordPress has a built-in HTTP API (wp_remote_get, wp_remote_post, etc.) and Query Monitor tracks everything that goes through it. This panel shows the URL, method, response code, and duration of each outbound HTTP request.

This is often where the worst offenders hide. Plugins that check for updates on every admin page load, themes that pull in external font metrics, integrations that hit third-party APIs synchronously during the request lifecycle – they all show up here. A single blocking HTTP call can add 500ms or more to a page load if the external service is slow.

The fix is usually caching: wrap the HTTP call in a transient so it only fires once per hour (or day, depending on how fresh the data needs to be). Query Monitor helps you find the calls; fixing them is usually a matter of adding WordPress transients around the expensive operations.

Common findings and what to do about them

After running Query Monitor on a dozen slow sites, certain patterns come up repeatedly:

  1. A WooCommerce or membership plugin running 50+ queries per page. Check if object caching is enabled. Most of those queries are likely cache misses that a Redis or Memcached object cache would eliminate.
  2. A slider or gallery plugin loading 10+ scripts and stylesheets on every page, including pages that have no slider. These plugins often enqueue assets globally and only conditionally render. The fix is usually a plugin-specific setting or a filter to conditionally enqueue.
  3. An SEO plugin with a slow hook callback that rebuilds metadata on every request. Look at the PHP Errors panel too – sometimes a deprecated function warning is causing extra processing.
  4. Post meta queries without indexes. If you’re querying _wp_attached_file or custom meta keys on large tables, check whether adding a meta_key index helps. This often requires a database-level intervention.

Query Monitor won’t fix your site for you, but it removes the guesswork. Before it, I’d approach a slow WordPress site by disabling plugins one by one and timing the result – effective but time-consuming. Now I open Query Monitor, look at the three panels above, and have a reasonable diagnosis in five minutes. If you’re doing any serious WordPress development or debugging, install it before you need it.

WordPress Hooks Explained: Actions vs Filters with Real Examples

If you’ve been working with WordPress for more than a week, you’ve probably heard the word “hooks” thrown around. Most tutorials describe them abstractly – event-driven architecture, observer pattern, blah blah. Let me skip that and go straight to what you actually need to know to use them.

The core difference

WordPress has two types of hooks: actions and filters.

Actions let you do something at a specific point in WordPress execution. You’re not expected to return anything – you just run your code and get out. Filters let you modify a value that WordPress is about to use. You receive the value, change it however you want, and return it. That return is not optional – if you forget it, things break in weird ways.

I’ve seen junior developers use add_action where they needed add_filter and then spend an hour wondering why their changes weren’t showing up. The rule is simple: if you’re transforming data, use a filter. If you’re just reacting to an event, use an action.

add_action in practice

The most common use case is hooking into WordPress’s initialization cycle. Here’s something I use in almost every theme – enqueueing scripts and styles properly instead of hardcoding them into templates:

function mytheme_enqueue_assets() {
    wp_enqueue_style(
        'mytheme-main',
        get_stylesheet_uri(),
        array(),
        '1.0.0'
    );

    wp_enqueue_script(
        'mytheme-app',
        get_template_directory_uri() . '/js/app.js',
        array( 'jquery' ),
        '1.0.0',
        true
    );
}
add_action( 'wp_enqueue_scripts', 'mytheme_enqueue_assets' );

The third argument to add_action is priority (default 10). Lower numbers run first. If you need to load something after another plugin has loaded its scripts, bump your priority to 20 or 30. I’ve had to do this with WooCommerce hooks more than once.

A second practical example – running code after a post is published:

function notify_on_publish( $new_status, $old_status, $post ) {
    if ( $new_status === 'publish' && $old_status !== 'publish' ) {
        // send a Slack notification, clear a cache, whatever
        do_something_with( $post->ID );
    }
}
add_action( 'transition_post_status', 'notify_on_publish', 10, 3 );

Note the fourth argument: 3. That tells WordPress to pass 3 arguments to your callback. The default is 1, and if you forget to specify this when your hook passes multiple arguments, you’ll only get the first one.

add_filter in practice

Filters are where things get interesting. One of the most-used filters is the_content, which runs on post content before it’s displayed:

function append_reading_time( $content ) {
    if ( ! is_single() ) {
        return $content;
    }

    $word_count = str_word_count( strip_tags( $content ) );
    $minutes    = max( 1, ceil( $word_count / 200 ) );
    $label      = $minutes === 1 ? '1 min read' : $minutes . ' min read';

    $suffix = '<p class="reading-time">' . esc_html( $label ) . '</p>';

    return $content . $suffix;
}
add_filter( 'the_content', 'append_reading_time' );

I’d recommend testing this kind of thing in your local WordPress dev environment first – filters on the_content can interact badly with page builders and block editor plugins if you’re not careful about the context check.

Another filter I use often is login_redirect. By default WordPress dumps everyone on the dashboard after login. This changes it based on role:

function custom_login_redirect( $redirect_to, $request, $user ) {
    if ( isset( $user->roles ) && is_array( $user->roles ) ) {
        if ( in_array( 'subscriber', $user->roles, true ) ) {
            return home_url( '/my-account/' );
        }
    }
    return $redirect_to;
}
add_filter( 'login_redirect', 'custom_login_redirect', 10, 3 );

Creating your own hooks

You’re not limited to hooks WordPress provides. In plugins or themes with multiple components, defining your own hooks is a clean way to let other code extend your functionality without editing your files directly. Use do_action() to fire an action and apply_filters() to create a filter point:

// In your plugin, fire an action at a logical point
do_action( 'myplugin_after_process', $result, $user_id );

// Create a filter so output can be modified externally
$output = apply_filters( 'myplugin_output_html', $default_html, $context );

This pattern is worth learning early – it’s how good plugins stay extensible without turning into spaghetti.

A few things that trip people up

  • Not returning in a filter callback. If you add a filter and forget return $value, WordPress gets null back. The content disappears or the option resets to empty. Always return.
  • Wrong argument count. When a hook passes 2+ arguments, you must set the fourth parameter of add_action/add_filter to match. Default is 1.
  • Hooking too late. Some hooks like init fire early in the request cycle. If you’re registering post types or taxonomies, you need to be there. Hooking into wp_loaded or later won’t work for those.
  • Naming collisions. If you name your callback function something generic like enqueue_scripts, another plugin might define the same function and cause a fatal error. Prefix everything with your theme or plugin slug.

The WordPress developer docs on hooks are actually quite good and list all the core hooks by category. Once you have the mental model down, that reference is all you need.

Removing hooks

One more thing worth knowing: you can remove hooks with remove_action and remove_filter. This is useful when a plugin or theme attaches a callback you want to override or disable entirely. The catch is timing – you have to call remove_action after the original add_action has run, but before the hook fires. Getting the priority right matters here too. If the original was added at priority 10, you have to remove it at priority 10 as well, otherwise WordPress won’t find it to remove.

Hooks are probably the single most important concept to internalize when working with WordPress. Everything else – themes, plugins, the block editor’s server-side rendering – builds on top of this system. Get comfortable with them early and the rest of WordPress development becomes a lot more predictable.

Setting Up a Local WordPress Dev Environment in 2026

Local WordPress development used to mean XAMPP, a lot of PATH headaches, and the occasional PHP version mismatch that would eat an afternoon. These days there are at least three decent options worth knowing about: wp-env, WordPress Studio, and a hand-rolled Docker Compose setup. I’ve used all three on different projects and they each have a distinct personality.

wp-env: the official Node-based option

wp-env ships as part of the Gutenberg tooling and is maintained by the WordPress core team. If you’re building blocks or plugins, it’s the obvious first choice – it creates a pair of WordPress installs (one for development, one for testing) and wires them up automatically.

Install it globally or per-project:

npm install -g @wordpress/env
# or locally
npm install --save-dev @wordpress/env

Then drop a .wp-env.json in your plugin or theme directory:

{
  "core": "WordPress/WordPress#6.5",
  "plugins": [ "." ],
  "themes": [],
  "config": {
    "WP_DEBUG": true,
    "WP_DEBUG_LOG": true
  },
  "port": 8888
}

Run wp-env start and you get WordPress at localhost:8888. The test environment lives at localhost:8889. Credentials are always admin / password – fine for local use.

What I like: zero fussing with Docker Compose files, built-in WP-CLI access via wp-env run cli wp ..., and it respects the wp-env.override.json pattern so you can have personal overrides without polluting the repo. What I don’t like: it’s opinionated about the directory structure, and if you’re working on a full site (not just a plugin), it can feel constraining. Documentation is at developer.wordpress.org.

WordPress Studio

WordPress Studio is a desktop GUI app from Automattic, released in 2024. Think of it as the spiritual successor to Local (by Flywheel) but leaner. You click “Add site”, give it a name, pick a PHP version, and it’s running in under a minute. No terminal required.

It’s good for: designers who need to test a theme, clients who want to poke around locally, or anyone who doesn’t want to maintain a Docker setup. For developers who live in the terminal, it’s fine but not particularly useful – you’ll end up opening a shell anyway to run WP-CLI or install Composer packages.

One practical limitation: Studio manages its own PHP and MySQL binaries, which means you can’t easily add PHP extensions or customize php.ini beyond what the UI exposes. If you need xdebug or a custom Redis object cache, Docker is a better fit.

Docker Compose: more setup, more control

I switched to Docker Compose for most client projects a couple of years back because it gives you a docker-compose.yml that lives in the repo and documents exactly what the environment looks like. New team member? docker compose up -d and they’re running.

Here’s a minimal but usable compose file for WordPress development:

services:
  db:
    image: mysql:8.0
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: rootpass
      MYSQL_DATABASE: wordpress
      MYSQL_USER: wp
      MYSQL_PASSWORD: wp
    volumes:
      - db_data:/var/lib/mysql

  wordpress:
    image: wordpress:6.5-php8.2-apache
    restart: unless-stopped
    ports:
      - "8080:80"
    environment:
      WORDPRESS_DB_HOST: db
      WORDPRESS_DB_USER: wp
      WORDPRESS_DB_PASSWORD: wp
      WORDPRESS_DB_NAME: wordpress
      WORDPRESS_DEBUG: 1
    volumes:
      - ./wp-content:/var/www/html/wp-content
      - ./uploads.ini:/usr/local/etc/php/conf.d/uploads.ini

  cli:
    image: wordpress:cli-php8.2
    depends_on:
      - db
      - wordpress
    volumes:
      - ./wp-content:/var/www/html/wp-content
    entrypoint: wp
    command: "--info"

volumes:
  db_data:

The uploads.ini file handles the classic “max upload size” problem:

file_uploads = On
memory_limit = 256M
upload_max_filesize = 64M
post_max_size = 64M
max_execution_time = 600

Mount only wp-content rather than the entire WordPress root – it keeps the volume lean and avoids accidentally overwriting core files. WP-CLI runs as a separate service to avoid permission issues that crop up when you run it from inside the main container.

For Xdebug, add this to the wordpress service and rebuild:

  wordpress:
    build:
      context: .
      dockerfile: Dockerfile.dev
# Dockerfile.dev
FROM wordpress:6.5-php8.2-apache
RUN pecl install xdebug \
 && docker-php-ext-enable xdebug
COPY xdebug.ini /usr/local/etc/php/conf.d/xdebug.ini

Docker’s own docs on volumes and networking cover the edge cases well: docs.docker.com/storage/volumes.

A note on database persistence and resets

One thing that bites people new to container-based WordPress dev: when you run wp-env clean all or docker compose down -v, you lose the database. That’s usually intentional – you want a clean state for tests – but it can be surprising the first time it happens on a project where you’d spent an hour importing a client’s database and configuring plugins.

For wp-env, use wp-env stop and wp-env start to pause and resume without destroying state. For Docker Compose, docker compose down without the -v flag preserves the named volume. I’ve started keeping a make db-export target in client projects that runs:

docker compose exec db mysqldump -u wp -pwp wordpress > ./db/snapshot.sql

And a corresponding make db-import that loads it back. Adds five minutes to the initial setup and has saved me multiple times.

Syncing with a staging environment

Local dev is only useful if it resembles production. For most WordPress projects that means getting the production database down to local, with URLs replaced. wp-env and Docker Compose both give you WP-CLI access, so the standard approach works in either case:

# after importing the prod DB dump
wp search-replace 'https://client-site.com' 'http://localhost:8080' --skip-columns=guid

A few things to watch: the --skip-columns=guid flag is important – replacing GUIDs breaks WordPress attachment tracking. Also run wp cache flush after the replace, because some plugins (object cache, page cache) may have stale URLs stored.

If you’re working with WooCommerce or a plugin that stores serialized URLs in the database, add --precise to the search-replace command. The default regex-based replacement can corrupt serialized data.

Which one to pick

If you’re building a Gutenberg block or a plugin and want to run automated tests, use wp-env – it’s the path of least resistance and the tooling integrates cleanly with @wordpress/scripts. If you’re handing off a site to a non-developer or want something that just works with a double-click, WordPress Studio is hard to argue with. If you’re working on a production-grade site, collaborating with a team, or need precise control over the PHP configuration, Docker Compose is worth the initial setup cost.

My day-to-day is mostly Docker Compose with the wp-env config checked into plugin repos for CI. Studio I keep installed for the occasional “can you quickly check how this looks in WordPress” request. All three tools have improved noticeably over the last year – the gap between them is narrower than it used to be, which is a good problem to have.