AJAX Routing

This document covers the WordPress AJAX routing features in detail.

Overview

The AjaxRouter provides a fluent API for registering WordPress AJAX actions with automatic nonce verification and JSON response handling.

Basic Usage

Create routes/ajax.php in your plugin:

<?php

use WPZylos\Framework\Routing\AjaxRouter;
use App\Controllers\Ajax\SearchController;
use App\Controllers\Ajax\SettingsController;

return function (AjaxRouter $ajax) {
    // Public action (accessible without login)
    $ajax->public('search', [SearchController::class, 'handle']);

    // Private action (requires login)
    $ajax->private('save_settings', [SettingsController::class, 'save']);
};

Public vs Private Actions

Private Actions (Default)

Only accessible to logged-in users:

$ajax->private('update_profile', [ProfileController::class, 'update']);

Registers: wp_ajax_{prefix}update_profile

Public Actions

Accessible to all users (logged in or not):

$ajax->public('search', [SearchController::class, 'handle']);

Registers both:

  • wp_ajax_{prefix}search
  • wp_ajax_nopriv_{prefix}search

Action Names

Actions are automatically prefixed with your plugin prefix:

DefinitionWordPress Hook
$ajax->private('save')wp_ajax_myplugin_save
$ajax->public('search')wp_ajax_myplugin_search + wp_ajax_nopriv_myplugin_search

Nonce Verification

Automatic Verification (Default)

By default, all routes verify the _wpnonce parameter:

$ajax->private('save_settings', [SettingsController::class, 'save']);
// Expects: $_REQUEST['_wpnonce'] with action 'myplugin_save_settings'

Disable Verification

For public endpoints that don't need nonce:

$ajax->public('public_search', [SearchController::class, 'search'])
    ->withoutNonce();

Custom Nonce Action

Override the nonce action name:

$ajax->private('special_action', [Controller::class, 'handle'])
    ->nonce('my_custom_action');
// Expects nonce with action 'myplugin_my_custom_action'

Response Handling

Automatic JSON Response

Responses are automatically wrapped in wp_send_json_success():

class SearchController
{
    public function handle(): array
    {
        return ['results' => $this->search()];
    }
    // Outputs: {"success": true, "data": {"results": [...]}}
}

Custom Response Format

Return a pre-formatted response:

public function handle(): array
{
    return [
        'success' => true,
        'data' => ['message' => 'Done'],
    ];
}

Error Responses

Throw exceptions for error handling:

public function handle(): array
{
    if (!$this->validate()) {
        throw new \Exception('Validation failed');
    }
    return ['status' => 'ok'];
}
// Outputs: {"success": false, "data": {"message": "Validation failed"}}

Controller Implementation

Basic Controller

<?php

namespace App\Controllers\Ajax;

class SearchController
{
    public function handle(): array
    {
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
        $query = sanitize_text_field($_POST['query'] ?? '');

        $results = new \WP_Query([
            's' => $query,
            'posts_per_page' => 10,
        ]);

        return [
            'results' => array_map(fn($post) => [
                'id' => $post->ID,
                'title' => $post->post_title,
                'url' => get_permalink($post),
            ], $results->posts),
            'total' => $results->found_posts,
        ];
    }
}

With Validation

class SettingsController
{
    public function save(): array
    {
        // Input is already nonce-verified by the router

        // phpcs:ignore WordPress.Security.NonceVerification.Missing
        $settings = $_POST['settings'] ?? [];

        if (empty($settings)) {
            throw new \InvalidArgumentException('Settings cannot be empty');
        }

        update_option('my_plugin_settings', $settings);

        return ['message' => 'Settings saved successfully'];
    }
}

Frontend Integration

Enqueue and Localize

add_action('wp_enqueue_scripts', function () use ($ajax) {
    wp_enqueue_script(
        'my-plugin-ajax',
        plugin_dir_url(__FILE__) . 'assets/ajax.js',
        ['jquery'],
        '1.0.0',
        true
    );

    wp_localize_script('my-plugin-ajax', 'MyPluginAjax', [
        'url' => admin_url('admin-ajax.php'),
        'nonce' => $ajax->nonce('search'),
        'actions' => [
            'search' => 'myplugin_search',
            'save' => 'myplugin_save_settings',
        ],
    ]);
});

JavaScript (jQuery)

jQuery(function ($) {
  $("#search-form").on("submit", function (e) {
    e.preventDefault();

    $.ajax({
      url: MyPluginAjax.url,
      method: "POST",
      data: {
        action: MyPluginAjax.actions.search,
        _wpnonce: MyPluginAjax.nonce,
        query: $("#search-input").val(),
      },
      success: function (response) {
        if (response.success) {
          displayResults(response.data.results);
        } else {
          showError(response.data.message);
        }
      },
      error: function () {
        showError("Request failed");
      },
    });
  });
});

JavaScript (Fetch API)

async function search(query) {
  const formData = new FormData();
  formData.append("action", "myplugin_search");
  formData.append("_wpnonce", MyPluginAjax.nonce);
  formData.append("query", query);

  const response = await fetch(MyPluginAjax.url, {
    method: "POST",
    body: formData,
  });

  const data = await response.json();

  if (data.success) {
    return data.data;
  } else {
    throw new Error(data.data.message);
  }
}

Helper Methods

Generate AJAX URL

$url = $ajax->url('search');
// Returns: https://example.com/wp-admin/admin-ajax.php?action=myplugin_search

Generate Nonce Field

echo $ajax->nonceField('save_settings');
// Outputs: <input type="hidden" name="_wpnonce" value="abc123..." />

Generate Nonce Value

$nonce = $ajax->nonce('save_settings');
// Returns: abc123...

Route Groups

Middleware Groups

$ajax->group(['middleware' => [AdminMiddleware::class]], function ($ajax) {
    $ajax->private('admin_action_1', [AdminController::class, 'action1']);
    $ajax->private('admin_action_2', [AdminController::class, 'action2']);
});

Middleware

Creating AJAX Middleware

class AdminMiddleware
{
    public function handle($request, callable $next): mixed
    {
        if (!current_user_can('manage_options')) {
            throw new \Exception('Unauthorized');
        }

        return $next($request);
    }
}

Applying Middleware

$ajax->private('admin_settings', [SettingsController::class, 'save'])
    ->middleware(AdminMiddleware::class);

Security Best Practices

1. Always Verify Nonces (Default)

The router verifies nonces automatically. Only disable for truly public, read-only endpoints.

2. Sanitize Input

// phpcs:ignore WordPress.Security.NonceVerification.Missing
$email = sanitize_email($_POST['email'] ?? '');
$title = sanitize_text_field($_POST['title'] ?? '');
$content = wp_kses_post($_POST['content'] ?? '');

3. Validate Capabilities

public function save(): array
{
    if (!current_user_can('edit_posts')) {
        throw new \Exception('Permission denied');
    }

    // ... proceed with save
}

4. Rate Limiting (Optional)

class RateLimitMiddleware
{
    public function handle($request, callable $next): mixed
    {
        $ip = $_SERVER['REMOTE_ADDR'];
        $key = 'rate_limit_' . md5($ip);

        $count = get_transient($key) ?: 0;

        if ($count > 100) {
            throw new \Exception('Rate limit exceeded');
        }

        set_transient($key, $count + 1, MINUTE_IN_SECONDS);

        return $next($request);
    }
}

Debugging

Check Registered Actions

add_action('init', function () {
    global $wp_filter;

    foreach ($wp_filter as $hook => $callbacks) {
        if (str_starts_with($hook, 'wp_ajax_myplugin_')) {
            error_log("Registered AJAX: {$hook}");
        }
    }
}, 999);

Test with cURL

curl -X POST https://example.com/wp-admin/admin-ajax.php \
  -d "action=myplugin_search" \
  -d "_wpnonce=YOUR_NONCE" \
  -d "query=test"