The WordPress course used admin-ajax.php for a front-end "favourite" button and showed the nonce check. This lesson takes AJAX further for plugin admin screens: a button on the Bookings page that changes a booking's status without reloading, with a handler that returns consistent JSON for every outcome. Then it compares the same feature as a REST route, so you can choose between them.
How admin-ajax.php works
Every request to wp-admin/admin-ajax.php must include an action parameter. WordPress loads fully, including the admin, and then fires one of two hooks:
wp_ajax_{action}when the user is logged in;wp_ajax_nopriv_{action}when they are not.
If no callback is attached, it responds with 0. Your callback reads the request, does the work and sends a response. Nothing else about routing, validation or permissions is built in: your handler does all of it.
The handler
<?php
// wp-content/plugins/myplugin/admin/ajax.php
defined( 'ABSPATH' ) || exit;
add_action( 'wp_ajax_myplugin_booking_status', 'myplugin_ajax_booking_status' );
/**
* Change a booking's status. Responds with JSON and ends the request.
*/
function myplugin_ajax_booking_status(): void {
// 1. Nonce. Pass false so we can answer in JSON instead of a bare -1.
if ( ! check_ajax_referer( 'myplugin_booking_status', 'nonce', false ) ) {
wp_send_json_error(
array( 'message' => __( 'Your session has expired. Reload the page and try again.', 'myplugin' ) ),
403
);
}
// 2. Capability.
if ( ! current_user_can( 'myplugin_manage_bookings' ) ) {
wp_send_json_error( array( 'message' => __( 'You are not allowed to change bookings.', 'myplugin' ) ), 403 );
}
// 3. Input.
$booking_id = isset( $_POST['booking_id'] ) ? absint( wp_unslash( $_POST['booking_id'] ) ) : 0;
$status = isset( $_POST['status'] ) ? sanitize_key( wp_unslash( $_POST['status'] ) ) : '';
if ( ! $booking_id || ! in_array( $status, MYPLUGIN_BOOKING_STATUSES, true ) ) {
wp_send_json_error( array( 'message' => __( 'Invalid booking or status.', 'myplugin' ) ), 400 );
}
// 4. The work, through the bookings functions.
$result = myplugin_set_booking_status( $booking_id, $status );
if ( is_wp_error( $result ) ) {
wp_send_json_error( array( 'message' => $result->get_error_message() ), 404 );
}
// 5. Success.
wp_send_json_success(
array(
'id' => $booking_id,
'status' => $status,
'label' => ucfirst( $status ),
)
);
}
The order is the same as every handler in this course: nonce and capability, then input, then work. Details that matter:
check_ajax_referer( $action, $field, false )returnsfalseinstead of stopping. Without the third argument, it ends the request with-1and a 403 status, which JavaScript expecting JSON cannot parse. A consistent JSON shape makes the client simpler.wp_send_json_success()andwp_send_json_error()send{"success": true, "data": ...}or{"success": false, "data": ...}, set the status code you pass, and end the request. Code after them never runs, so there is no need forexit.- There is no
wp_ajax_nopriv_hook, so logged-out visitors cannot reach this action at all.
The status function belongs with the other booking functions:
<?php
// wp-content/plugins/myplugin/includes/bookings.php
/**
* Update a booking's status.
*
* @return true|WP_Error
*/
function myplugin_set_booking_status( int $booking_id, string $status ) {
global $wpdb;
$table = myplugin_bookings_table();
$booking = $wpdb->get_row( $wpdb->prepare( 'SELECT id, event_id FROM %i WHERE id = %d', $table, $booking_id ) );
if ( ! $booking ) {
return new WP_Error( 'myplugin_no_booking', __( 'Booking not found.', 'myplugin' ) );
}
$wpdb->update(
$table,
array(
'status' => $status,
'updated_at' => current_time( 'mysql', true ),
),
array( 'id' => $booking_id ),
array( '%s', '%s' ),
array( '%d' )
);
wp_cache_delete( 'guests_' . (int) $booking->event_id, 'myplugin_bookings' );
do_action( 'myplugin_booking_status_changed', $booking_id, $status );
return true;
}
Loading the script on one screen
Load the handler file from the main file inside the is_admin() block, because admin-ajax.php requests are admin requests. Enqueue the script from the Bookings screen's load hook, as in the admin pages lesson, and pass the URL and nonce to it:
<?php
// wp-content/plugins/myplugin/admin/menu.php, called from myplugin_bookings_screen_load().
function myplugin_bookings_assets(): void {
wp_enqueue_script(
'myplugin-bookings',
MYPLUGIN_URL . 'assets/js/bookings.js',
array(),
MYPLUGIN_VERSION,
array( 'in_footer' => true )
);
wp_add_inline_script(
'myplugin-bookings',
'const mypluginBookings = ' . wp_json_encode(
array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'myplugin_booking_status' ),
'failed' => __( 'The booking could not be updated.', 'myplugin' ),
)
) . ';',
'before'
);
}
In the admin, WordPress also defines a global ajaxurl variable. Passing the URL explicitly works everywhere, including the front end, and keeps the script self-contained.
In the Bookings table from the admin pages lesson, give the status cell a class, <td class="myplugin-status">, and add a cell with a button that carries the booking ID and the new status:
<button type="button" class="button myplugin-set-status" data-id="<?php echo esc_attr( (string) $booking->id ); ?>" data-status="confirmed">
<?php esc_html_e( 'Confirm', 'myplugin' ); ?>
</button>
The JavaScript
// wp-content/plugins/myplugin/assets/js/bookings.js
document.addEventListener( 'click', async ( event ) => {
const button = event.target.closest( '.myplugin-set-status' );
if ( ! button ) {
return;
}
button.disabled = true;
const body = new FormData();
body.append( 'action', 'myplugin_booking_status' );
body.append( 'nonce', mypluginBookings.nonce );
body.append( 'booking_id', button.dataset.id );
body.append( 'status', button.dataset.status );
try {
const response = await fetch( mypluginBookings.ajaxUrl, {
method: 'POST',
credentials: 'same-origin',
body,
} );
const result = await response.json();
if ( ! result.success ) {
throw new Error( result.data?.message || mypluginBookings.failed );
}
button.closest( 'tr' ).querySelector( '.myplugin-status' ).textContent = result.data.label;
} catch ( error ) {
window.alert( error.message || mypluginBookings.failed );
} finally {
button.disabled = false;
}
} );
One listener on the document handles every button, including rows added later. The button is disabled during the request so a double click does not send two changes. Both failure paths, a network error and success: false, end in the same message. Writing the label with textContent rather than innerHTML means a malicious value could never become markup.
Public actions
A wp_ajax_nopriv_ handler is reachable by anyone on the internet, including bots. Treat it like a public form:
- Nonces offer little protection here, because every logged-out visitor is the same user to WordPress, and a cached page can serve a nonce long after it was created.
- Validate everything and limit what a single request can do.
- Add rate limiting, for example a short transient keyed by IP address, for anything that sends email or writes data.
- Never return data a visitor could not already see on the site.
The admin-ajax.php performance traps
Every AJAX request loads the whole of WordPress plus the admin: admin_init fires, and so does every plugin's admin_init code. Two problems follow.
First, it is slow for frequent requests. A search-as-you-type box that calls admin-ajax.php on every keystroke can put serious load on a server, and responses to POST requests cannot be served from a page cache.
Second, code on admin_init that redirects or prints can break every AJAX request on the site, including other plugins'. If your plugin redirects on admin_init, for example to a welcome screen, skip AJAX requests:
if ( wp_doing_ajax() ) {
return;
}
The same feature as a REST route
Here is the status change as a REST route, for comparison:
<?php
// wp-content/plugins/myplugin/includes/rest.php
add_action( 'rest_api_init', static function () {
register_rest_route(
'myplugin/v1',
'/bookings/(?P<id>\d+)/status',
array(
'methods' => WP_REST_Server::EDITABLE,
'permission_callback' => static fn() => current_user_can( 'myplugin_manage_bookings' ),
'args' => array(
'status' => array(
'type' => 'string',
'enum' => MYPLUGIN_BOOKING_STATUSES,
'required' => true,
),
),
'callback' => static function ( WP_REST_Request $request ) {
$result = myplugin_set_booking_status( (int) $request['id'], $request['status'] );
return is_wp_error( $result ) ? $result : rest_ensure_response( array( 'status' => $request['status'] ) );
},
)
);
} );
The enum replaces the allowlist check, the permission callback replaces the capability check, and the wp_rest nonce sent by @wordpress/api-fetch replaces your own. Because the business logic lives in myplugin_set_booking_status(), both versions are thin wrappers.
| admin-ajax.php | REST API | |
|---|---|---|
| URL | One URL, routed by action | A URL per resource, with HTTP methods |
| Validation | Write it yourself | Declared in args: types, enums, ranges |
| Permissions | Write it yourself | permission_callback, required on every route |
| Loads the admin | Yes | No |
| Used by the block editor and headless front ends | No | Yes |
| Discoverable and documented | No | Listed under /wp-json/ with its schema |
Choose REST for new features, especially anything the block editor, a headless front end or another system calls. AJAX remains reasonable for small admin-only interactions in classic screens, and you will keep meeting it in existing plugins, so you need to read and secure it confidently.
Summary
admin-ajax.phproutes byactiontowp_ajax_{action}andwp_ajax_nopriv_{action}; with no handler it responds with0.- Every handler checks a nonce and a capability, validates input with allowlists, and does the work through shared functions.
- Use
check_ajax_referer()withfalseandwp_send_json_error()with a status code for consistent JSON;wp_send_json_*()ends the request. noprivhandlers are public endpoints: validate, rate-limit and expose nothing private.admin-ajax.phploads the admin on every request; prefer REST routes for new features and keep logic in functions both can call.