Back to blog
WordPressPHPAIBackend

Shipping an AI Plugin for WordPress: What the PHP Side Taught Me

May 20, 20264 min readby Niloy Mahmud Apu

A lot of "AI WordPress plugin" tutorials stop at: call the API, echo the response, done. Building one that real sites install is a different sport. WordPress runs on a staggering range of hosts, PHP versions, and themes, and your plugin has to behave on all of them without taking the site down.

Here are the lessons that stuck with me building and maintaining one at work.

WordPress is an event system, not a framework

The mental shift that unlocks everything: you don't control the request, you hook into it. Almost nothing happens directly — you register callbacks and WordPress calls them at the right moment.

add_action( 'admin_menu', 'myplugin_register_settings_page' );
add_action( 'rest_api_init', 'myplugin_register_routes' );
add_filter( 'the_content', 'myplugin_maybe_inject_summary' );

Once you think in hooks (add_action) and filters (add_filter), the rest of the platform stops feeling random. Your AI features are just callbacks that fire on the right event.

Never call the model from the front-end request

The single biggest mistake is making an LLM call inline while rendering a page. A model call can take several seconds; a slow upstream means a slow (or timed-out) page for every visitor.

Two patterns fixed this for us:

  1. Expose a REST route and call the model from the browser after the page loads, so the page never blocks on the API.
  2. Cache aggressively with transients, keyed on the input, so identical requests never hit the API twice.
register_rest_route( 'myplugin/v1', '/summarize', array(
    'methods'             => 'POST',
    'callback'            => 'myplugin_summarize',
    'permission_callback' => function () {
        return current_user_can( 'edit_posts' );
    },
) );

function myplugin_summarize( WP_REST_Request $request ) {
    $text = $request->get_param( 'text' );
    $key  = 'myplugin_sum_' . md5( $text );

    $cached = get_transient( $key );
    if ( false !== $cached ) {
        return rest_ensure_response( array( 'summary' => $cached ) );
    }

    $summary = myplugin_call_model( $text ); // wp_remote_post under the hood
    set_transient( $key, $summary, DAY_IN_SECONDS );

    return rest_ensure_response( array( 'summary' => $summary ) );
}

Note the permission_callback. A REST route without one is a public endpoint — and a public endpoint that spends your API credits is a bill waiting to happen.

Security is non-negotiable, and WordPress gives you the tools

Three things protect every write path in the plugin:

  • Nonces — verify the request came from your own UI, not a forged form: wp_verify_nonce().
  • Capability checkscurrent_user_can() so a subscriber can't trigger admin-only actions.
  • Sanitize in, escape outsanitize_text_field() on the way in, esc_html() / esc_attr() on the way out. Treat the LLM's output as untrusted too; it can return HTML you didn't ask for.

wp_remote_post, not cURL

It's tempting to reach for raw cURL. Don't. wp_remote_post() respects the site's proxy settings, timeouts, and SSL config, and it's mockable in tests. Always set a sane timeout and handle the error pathis_wp_error() is not optional, because the API will be down at some point.

$response = wp_remote_post( $endpoint, array(
    'timeout' => 30,
    'headers' => array( 'Authorization' => 'Bearer ' . $api_key ),
    'body'    => wp_json_encode( $payload ),
) );

if ( is_wp_error( $response ) ) {
    return new WP_Error( 'upstream_failed', 'The AI service is unavailable.' );
}

Store the API key like it's radioactive

It is. Keep it in the options table, never in code, never echoed back into a settings field as plain text, and gate the settings page behind manage_options. On multisite, decide deliberately whether the key is per-site or network-wide — getting that wrong leaks one client's key to another.

The takeaway

Adding AI to WordPress is 10% the model call and 90% being a good citizen of someone else's site: hook in cleanly, never block the page, cache, sanitize, and fail gracefully. Do that, and the "AI" part is the easy bit.