I spend most of my day in the terminal and inside PHP files. Running a team at Wbcom Designs means I touch a lot of legacy WordPress codebases, some well-architected, some built in 2013 with globals everywhere and no namespace in sight. When Claude Opus 4.7 launched with its 1M token context window, I loaded an entire plugin codebase into a single prompt and asked it to trace an auth flow. That changed how I work.

This post is about concrete backend tasks: designing REST endpoints with real auth patterns, writing WP-CLI commands, optimizing database queries, and wiring up background job queues. I’ll show you actual code that came out of sessions with Claude Opus 4.7, what was good, and where I had to push back and fix things.

Why Backend WordPress Dev Is a Good Match for Claude Opus 4.7

The 1M context window matters specifically for backend work. On the frontend you’re mostly styling components, context is less critical. On the backend you’re dealing with plugin architecture that spans 40+ files, hook chains where the callback lives three files from the registration, and database schemas with foreign-key relationships that only make sense if you can see the whole picture at once.

I loaded one of our heavier plugins, about 180KB of PHP, into a single Claude Opus 4.7 session. I asked: “Trace how a BuddyPress group activity post triggers our custom notification queue. Show me every hook, every function, every database write, in order.” It gave me a 14-step trace that was accurate. That kind of codebase-wide reasoning used to require me sitting down with Xdebug and stepping through manually.

That’s the entry point. Now let’s get to actual code.

Designing Custom REST Endpoints: Auth Patterns That Actually Hold Up

The WordPress REST API is where most backend work lives now. Third-party apps, mobile frontends, headless setups, they all hit REST. The default WordPress endpoints are fine for CRUD on posts and users, but the moment you need domain logic, you register your own routes.

I gave Claude Opus 4.7 this brief: “I need a REST endpoint at /wp-json/vapvarun/v1/orders that accepts POST requests. It should require either application password auth or a valid JWT from our plugin. It needs to validate the user has the manage_woocommerce capability. Sanitize and validate all incoming fields: order_id (integer), status (enum: pending|processing|completed|cancelled), notes (text). Return a proper WP_REST_Response with status codes.”

Here’s what it produced, and it’s largely what we deployed:

add_action( 'rest_api_init', function() {
    register_rest_route(
        'vapvarun/v1',
        '/orders',
        [
            'methods'             => WP_REST_Server::CREATABLE,
            'callback'            => 'vapvarun_handle_order_update',
            'permission_callback' => 'vapvarun_orders_permission_check',
            'args'                => [
                'order_id' => [
                    'required'          => true,
                    'type'              => 'integer',
                    'sanitize_callback' => 'absint',
                    'validate_callback' => function( $value ) {
                        return $value > 0;
                    },
                ],
                'status' => [
                    'required'          => true,
                    'type'              => 'string',
                    'sanitize_callback' => 'sanitize_text_field',
                    'validate_callback' => function( $value ) {
                        return in_array(
                            $value,
                            [ 'pending', 'processing', 'completed', 'cancelled' ],
                            true
                        );
                    },
                ],
                'notes' => [
                    'required'          => false,
                    'type'              => 'string',
                    'sanitize_callback' => 'sanitize_textarea_field',
                    'default'           => '',
                ],
            ],
        ]
    );
} );

function vapvarun_orders_permission_check( WP_REST_Request $request ) {
    // Attempt application password auth first (built into WP core since 5.6).
    // If the Authorization header is present and starts with "Basic", WP core
    // already processed it before this callback runs.
    if ( is_user_logged_in() ) {
        if ( ! current_user_can( 'manage_woocommerce' ) ) {
            return new WP_Error(
                'rest_forbidden_capability',
                __( 'You do not have permission to manage orders.', 'vapvarun' ),
                [ 'status' => 403 ]
            );
        }
        return true;
    }

    // Fall through: check custom JWT header.
    $token = $request->get_header( 'X-Vapvarun-Token' );
    if ( $token ) {
        $user_id = vapvarun_validate_jwt( $token );
        if ( $user_id ) {
            wp_set_current_user( $user_id );
            if ( user_can( $user_id, 'manage_woocommerce' ) ) {
                return true;
            }
        }
    }

    return new WP_Error(
        'rest_not_logged_in',
        __( 'Authentication required.', 'vapvarun' ),
        [ 'status' => 401 ]
    );
}

function vapvarun_handle_order_update( WP_REST_Request $request ) {
    $order_id = $request->get_param( 'order_id' );
    $status   = $request->get_param( 'status' );
    $notes    = $request->get_param( 'notes' );

    $order = wc_get_order( $order_id );
    if ( ! $order ) {
        return new WP_REST_Response(
            [ 'error' => 'Order not found.' ],
            404
        );
    }

    $order->set_status( $status, $notes, true );
    $order->save();

    return new WP_REST_Response(
        [
            'success'  => true,
            'order_id' => $order_id,
            'status'   => $order->get_status(),
        ],
        200
    );
}

A few things I had to fix after Claude generated this:

  • The original had wp_set_current_user() inside the permission callback without verifying if WP had already set a user via application passwords. I moved the JWT fallback after the is_user_logged_in() check to avoid overriding a valid core auth.
  • Claude used sanitize_text_field on the notes field initially. I changed that to sanitize_textarea_field since notes can span multiple lines.
  • It returned a plain array for the 404 instead of a WP_Error, I left it as a WP_REST_Response with a 404 code because the consuming app expected JSON, not a WordPress error object.

Application Passwords vs JWT: When to Use Which

Application passwords (built into WordPress core since 5.6) work well for server-to-server requests from trusted systems. They send a Base64-encoded “username:app-password” via the Authorization header. WordPress core validates this before your code runs, no library needed.

JWT makes sense when you have a mobile app or SPA that needs short-lived tokens with expiry control. You issue a token on login, the client stores it, and each request sends it as a header. You manage the secret, the expiry, and the validation yourself, which means more surface area for bugs.

I asked Claude to help me think through the threat model: “Which auth method is harder to exploit if the token leaks?” Its answer was clear: application passwords can be individually revoked from the WordPress admin without touching code. JWTs stay valid until they expire unless you build a blacklist. For internal tooling, application passwords win on operability.

Writing WP-CLI Commands With Claude Assist

WP-CLI is where I do a lot of my heavy lifting: bulk migrations, data cleanup, scheduled jobs that need progress output. Writing a well-structured WP-CLI command, with proper sub-commands, argument validation, and progress reporting, takes time to get right.

My prompt to Claude: “Write a WP-CLI command class called Vapvarun_Order_Sync_Command. It should have a sub-command ‘sync’ that accepts –batch-size (default 100), –dry-run (flag), and –status (string, default ‘processing’). It should pull WooCommerce orders in batches, check each against an external API, update status if needed, and report progress. Include proper error handling and a summary at the end.”

/**
 * Syncs WooCommerce order statuses against an external API.
 *
 * ## EXAMPLES
 *
 *   wp vapvarun order-sync sync --batch-size=50
 *   wp vapvarun order-sync sync --dry-run --status=pending
 */
class Vapvarun_Order_Sync_Command {

    /**
     * Syncs orders against the external fulfillment API.
     *
     * ## OPTIONS
     *
     * [--batch-size=]
     * : Number of orders to process per batch.
     * ---
     * default: 100
     * ---
     *
     * [--dry-run]
     * : Run without making changes. Outputs what would change.
     *
     * [--status=]
     * : WooCommerce order status to filter by.
     * ---
     * default: processing
     * ---
     *
     * @param array $args       Positional arguments.
     * @param array $assoc_args Named arguments.
     */
    public function sync( array $args, array $assoc_args ): void {
        $batch_size = (int) WP_CLI\Utils\get_flag_value( $assoc_args, 'batch-size', 100 );
        $dry_run    = (bool) WP_CLI\Utils\get_flag_value( $assoc_args, 'dry-run', false );
        $status     = sanitize_text_field(
            WP_CLI\Utils\get_flag_value( $assoc_args, 'status', 'processing' )
        );

        WP_CLI::log( sprintf(
            'Starting order sync. Status: %s | Batch size: %d | Dry run: %s',
            $status,
            $batch_size,
            $dry_run ? 'yes' : 'no'
        ) );

        $page        = 1;
        $total       = 0;
        $updated     = 0;
        $errors      = 0;

        do {
            $orders = wc_get_orders( [
                'status'   => $status,
                'limit'    => $batch_size,
                'page'     => $page,
                'return'   => 'objects',
            ] );

            if ( empty( $orders ) ) {
                break;
            }

            $progress = WP_CLI\Utils\make_progress_bar(
                'Processing batch ' . $page,
                count( $orders )
            );

            foreach ( $orders as $order ) {
                $order_id        = $order->get_id();
                $external_status = vapvarun_check_external_status( $order_id );

                if ( is_wp_error( $external_status ) ) {
                    WP_CLI::warning( sprintf(
                        'Order #%d: API error -- %s',
                        $order_id,
                        $external_status->get_error_message()
                    ) );
                    $errors++;
                    $progress->tick();
                    continue;
                }

                if ( $external_status !== $order->get_status() ) {
                    if ( ! $dry_run ) {
                        $order->set_status( $external_status, 'Synced via WP-CLI', true );
                        $order->save();
                    }
                    WP_CLI::log( sprintf(
                        'Order #%d: %s to %s%s',
                        $order_id,
                        $order->get_status(),
                        $external_status,
                        $dry_run ? ' [dry run]' : ''
                    ) );
                    $updated++;
                }

                $total++;
                $progress->tick();
            }

            $progress->finish();
            $page++;

        } while ( count( $orders ) === $batch_size );

        WP_CLI::success( sprintf(
            'Done. Orders scanned: %d | Updated: %d | Errors: %d',
            $total,
            $updated,
            $errors
        ) );
    }
}

WP_CLI::add_command( 'vapvarun order-sync', 'Vapvarun_Order_Sync_Command' );

Claude got the structure right on the first pass: proper doc-block format (which WP-CLI uses to generate help text), WP_CLI\Utils\get_flag_value for arguments, the progress bar pattern, and the do-while pagination loop that breaks when a batch comes back short.

One thing it missed: it did not wrap the external API call in a proper timeout. Real API calls need a timeout argument on wp_remote_get/wp_remote_post, I added 'timeout' => 10 to the vapvarun_check_external_status function. Without that, one slow API response can hang the whole WP-CLI process indefinitely.

I loaded 180KB of PHP into a single Claude Opus 4.7 session and got a 14-step hook trace that was accurate. That kind of codebase-wide reasoning used to require Xdebug and hours of stepping through manually.
I loaded 180KB of PHP into a single Claude Opus 4.7 session and got a 14-step hook trace that was accurate. That kind of codebase-wide reasoning used to require Xdebug and hours of stepping through manually.

Database Query Optimization: Before and After With Claude

Legacy WordPress code is full of queries that made sense in 2015 and kill performance at scale. Here’s a real example from a plugin we took over. The original code fetched active members from a BuddyPress group:

// Before -- what we inherited
function get_active_group_members( int $group_id ): array {
    global $wpdb;

    $member_ids = $wpdb->get_col(
        $wpdb->prepare(
            "SELECT user_id FROM {$wpdb->prefix}bp_groups_members
             WHERE group_id = %d AND is_confirmed = 1",
            $group_id
        )
    );

    $active_members = [];
    foreach ( $member_ids as $user_id ) {
        $last_activity = get_user_meta( $user_id, 'last_activity', true );
        if ( strtotime( $last_activity ) > strtotime( '-30 days' ) ) {
            $active_members[] = get_userdata( $user_id );
        }
    }

    return $active_members;
}

This runs one query to get member IDs, then two more queries per member (one for the meta, one for userdata). On a group with 500 members, that’s over 1,000 database queries per function call.

I pasted this to Claude with the schema for wp_usermeta and wp_bp_groups_members and asked it to rewrite this as a single join query, still returning WP_User-compatible data. Here’s what it gave back:

// After -- single query with JOIN
function get_active_group_members( int $group_id ): array {
    global $wpdb;

    $threshold = gmdate( 'Y-m-d H:i:s', strtotime( '-30 days' ) );

    $rows = $wpdb->get_results(
        $wpdb->prepare(
            "SELECT
                gm.user_id,
                u.user_login,
                u.user_email,
                u.display_name,
                um.meta_value AS last_activity
             FROM {$wpdb->prefix}bp_groups_members AS gm
             INNER JOIN {$wpdb->users} AS u
                 ON u.ID = gm.user_id
             INNER JOIN {$wpdb->usermeta} AS um
                 ON um.user_id = gm.user_id
                 AND um.meta_key = 'last_activity'
             WHERE gm.group_id = %d
               AND gm.is_confirmed = 1
               AND um.meta_value >= %s",
            $group_id,
            $threshold
        ),
        ARRAY_A
    );

    if ( ! $rows ) {
        return [];
    }

    return array_map( function( array $row ) {
        return get_userdata( (int) $row['user_id'] );
    }, $rows );
}

This is one query. It cuts 1,000+ queries down to one, and on groups with 500+ members we saw response time drop from 4.2 seconds to 180ms in our testing.

A note on what Claude did well here: it used gmdate instead of date (timezone-safe), it passed the threshold as a prepared parameter instead of interpolating the string directly, and it preserved the get_userdata return type so the rest of the codebase did not need changes. These are details that suggest it understood WordPress conventions, not just SQL.

One thing to watch: if last_activity is stored inconsistently (some rows with microseconds, some without), the >= string comparison on a VARCHAR column breaks. I added an INDEX hint to the query and validated the meta_value format first. Claude flagged this as a potential issue when I asked it to review its own output, which is a useful habit, always ask it to audit what it just generated.

Cron Logic and Background Job Queues

WordPress cron is unreliable for heavy work. If you have a job that takes 30 seconds and WP-Cron only fires when someone visits the site, you get gaps. The pattern that works for us is Action Scheduler (bundled with WooCommerce, also available standalone) for background processing, with WP-Cron handling the scheduling only.

I asked Claude to help me design a notification queue system. The requirement: when an order changes status, queue an email notification to be sent in the background rather than blocking the request. It should retry on failure, with a cap of 3 attempts.

/**
 * Queue a notification when an order status changes.
 * Uses Action Scheduler for reliable background delivery.
 */
add_action( 'woocommerce_order_status_changed', function(
    int    $order_id,
    string $from_status,
    string $to_status
) {
    // Only notify on transitions we care about.
    $notify_on = [ 'processing', 'completed', 'cancelled' ];
    if ( ! in_array( $to_status, $notify_on, true ) ) {
        return;
    }

    as_schedule_single_action(
        time() + 5, // Small delay lets order save fully complete.
        'vapvarun_send_order_notification',
        [
            'order_id'   => $order_id,
            'new_status' => $to_status,
            'attempt'    => 1,
        ],
        'vapvarun-notifications'
    );
}, 10, 3 );

/**
 * Action Scheduler handler: send the notification, retry on failure.
 *
 * @param int    $order_id   The WooCommerce order ID.
 * @param string $new_status The new order status.
 * @param int    $attempt    Current attempt number (1-indexed).
 */
add_action( 'vapvarun_send_order_notification', function(
    int    $order_id,
    string $new_status,
    int    $attempt
) {
    $max_attempts = 3;
    $order        = wc_get_order( $order_id );

    if ( ! $order ) {
        // Order deleted -- nothing to notify about.
        return;
    }

    $result = vapvarun_dispatch_notification( $order, $new_status );

    if ( is_wp_error( $result ) ) {
        if ( $attempt < $max_attempts ) {
            // Exponential backoff: 1m, 5m, 25m.
            $delay = (int) pow( 5, $attempt ) * 60;
            as_schedule_single_action(
                time() + $delay,
                'vapvarun_send_order_notification',
                [
                    'order_id'   => $order_id,
                    'new_status' => $new_status,
                    'attempt'    => $attempt + 1,
                ],
                'vapvarun-notifications'
            );
        } else {
            // Max attempts reached -- log for manual review.
            error_log( sprintf(
                '[vapvarun] Notification failed after %d attempts. Order #%d, Status: %s. Error: %s',
                $max_attempts,
                $order_id,
                $new_status,
                $result->get_error_message()
            ) );
        }
    }
}, 10, 3 );

Claude got the Action Scheduler API right, including the group parameter (useful for monitoring queues by plugin) and the argument-passing pattern. The exponential backoff math was correct: pow(5, 1)*60 = 300s (5m), pow(5, 2)*60 = 1500s (25m).

What I changed: the original had the max attempt check as $attempt <= $max_attempts, an off-by-one error that would schedule a 4th attempt. I fixed that to $attempt < $max_attempts. Also, I added the 5-second initial delay. Without it, on high-traffic sites the action can fire before the order’s post_status is committed to the database, causing a race condition where the order still shows the old status when the notification fires.

Plugin Architecture Refactoring With a 1M Context Window

The most powerful use of Claude Opus 4.7 for backend work is architecture review on code you did not write. When we take over a plugin for a client, the first thing I do now is paste the entire codebase, every PHP file, and ask Claude to identify architectural problems.

A recent example: a 15,000-line plugin with no class structure, everything in functions.php-style global functions. I asked Claude: “Review this plugin for architectural problems. Flag: singletons that should be service classes, globals that should be dependency-injected, hooks registered in wrong places (e.g. inside other hook callbacks), direct database queries that bypass the WP data layer, and any places where nonce verification is missing.”

It returned a 47-point list grouped by severity. The top issues it found:

  • Three places where register_rest_route was called inside a hook that itself fired inside init. This caused routes to only register on some page loads depending on hook priority.
  • Direct $wpdb->query() calls without $wpdb->prepare(), actual SQL injection vulnerabilities, not theoretical ones.
  • A settings page that used $_POST directly without nonce verification or capability checks.
  • An options cache stored as a class property that persisted across requests in object caching environments, causing stale data bugs.

That last one is subtle. When object caching is enabled (Redis, Memcached), PHP class properties that hold WP options can get out of sync because the class is instantiated fresh per request, but if you bootstrap it early and cache into a property, then the option updates mid-request and the cached property is stale. Claude caught this because it could see both the cache initialization code and the settings update handler in the same context window.

Without a 1M context window, catching that kind of cross-file bug requires either very good code navigation or manually correlating files. Claude made it a five-minute exercise.

The Workflow I Use Day to Day

Here is how I actually integrate Claude Opus 4.7 into backend WP work, not as a summary but as a literal sequence:

  1. Architecture session first. Before touching code, paste the full plugin into Claude and ask for a structural review. This takes 2-3 minutes and surfaces problems that would take hours to find manually.
  2. Write the spec, not the code. I describe what I want in technical terms, endpoint path, auth method, capability check, argument types, return shape. Claude writes the first pass. I review for WordPress idioms it might have missed.
  3. Always ask it to audit its own output. After any code generation, I follow up with: “Review what you just wrote. Flag any security issues, performance problems, or WordPress-specific mistakes.” It catches its own off-by-one errors and missing timeout arguments about 60% of the time.
  4. Test with real data, not fixtures. Claude can write test scaffolding, but I validate against real WooCommerce orders and real BuddyPress groups. Edge cases in real data, null meta values, deleted users, orders with no line items, reveal gaps that clean fixtures miss.
  5. Use it for code review on PRs. Before a PR goes to the team, I paste the diff into Claude and ask it to review for security and correctness. It’s not a replacement for human review, but it catches mechanical issues and frees human reviewers to focus on architecture and product logic.

Where Claude Opus 4.7 Still Gets It Wrong

It’s worth being direct about the failure modes, because if you take Claude’s output as final without review, you’ll ship bugs.

WP-CLI Argument Parsing Edge Cases

Claude sometimes uses $assoc_args['key'] directly instead of WP_CLI\Utils\get_flag_value. The difference matters: direct array access throws a PHP notice if the flag is not passed; get_flag_value returns the default safely. Always check argument retrieval patterns in generated WP-CLI code.

Multisite Assumptions

Unless you explicitly tell Claude you’re on a multisite, it writes for single-site. Table prefixes, user capability checks, and options storage all behave differently on multisite. If your plugin needs to support both, specify it in the prompt and review every get_option / update_option call to see if it should be get_site_option.

Deprecated Function Usage

Claude occasionally reaches for functions that were deprecated in WP 5.x or 6.x. It used get_currentuserinfo() in one session, deprecated since 4.5. Always run generated code through a static analysis tool (PHPStan with the WordPress stubs, or PHPCS with the WordPress Coding Standards ruleset) before considering it done. These tools catch the deprecated-function calls that code review might miss.

It Doesn’t Know Your Codebase Conventions

This is the big one. Claude knows WordPress. It does not know that your team prefixes every hook with myplugin_, or that you have a helper function myplugin_get_order that wraps wc_get_order and adds logging. You have to tell it your conventions explicitly, or spend time refactoring its output to match your codebase style. I keep a short “conventions” document I paste into any new Claude session before writing code.

What This Means for Backend WP Dev Teams

At Wbcom Designs, we build custom BuddyPress and WooCommerce functionality for clients. The backend work, REST APIs, background jobs, database optimization, used to be the part of the work that could not be parallelized or sped up without hiring more senior developers.

Claude Opus 4.7 does not replace senior developers. It compresses the time between “spec” and “working draft” from hours to minutes. A junior developer with a good spec and Claude can produce a first draft that a senior developer can review rather than write from scratch. That changes the throughput of a backend team significantly.

The catch is that the review step is not optional. Every piece of AI-generated backend code needs a human who understands WordPress security, WP-CLI conventions, and WooCommerce data models. The junior developer using Claude without that review loop will ship bugs that look fine until they hit production on a multisite with Redis active.

If you’re exploring ways to keep AI tooling costs down while running backend experiments, I wrote about building WordPress plugins with Groq and DeepSeek’s free API tier, useful context for budget-conscious backend teams. And if you want to know how the 1M context approach stacks up against self-hosted options, the post on running Llama 4 Scout on your own server for WordPress agency work covers that tradeoff in depth.

If you want to talk through how we handle backend WordPress development for client projects, REST API design, background jobs, database architecture, reach out via the contact page. These are the kinds of problems we work on every day at Wbcom Designs.