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}searchwp_ajax_nopriv_{prefix}search
Action Names
Actions are automatically prefixed with your plugin prefix:
| Definition | WordPress 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"