Build WordPress plugins from scratch: actions and filters, shortcodes, settings pages, custom post types, meta boxes, AJAX, REST endpoints, custom tables, cron and blocks.
Lessons
AJAX lets a page talk to WordPress without reloading: your JavaScript sends a request to admin-ajax.php with an action name, WordPress runs the PHP callback registered for that action, and the callback replies with JSON.
action=gdc_books_favourite, the book ID and a nonce to admin-ajax.php.wp_ajax_gdc_books_favourite (logged in) or wp_ajax_nopriv_gdc_books_favourite (logged out).wp_send_json_success().<?php
function gdc_books_ajax_favourite() {
// Dies with -1 and a 403 if the nonce is invalid.
check_ajax_referer( 'gdc_books_favourite', 'nonce' );
$user_id = get_current_user_id();
$book_id = isset( $_POST['book_id'] ) ? absint( $_POST['book_id'] ) : 0;
if ( ! $book_id || 'gdc_book' !== get_post_type( $book_id ) ) {
wp_send_json_error( array( 'message' => __( 'Unknown book.', 'gdc-books' ) ), 400 );
}
$favourites = (array) get_user_meta( $user_id, 'gdc_books_favourites', true );
$is_saved = in_array( $book_id, $favourites, true );
if ( $is_saved ) {
$favourites = array_values( array_diff( $favourites, array( $book_id ) ) );
} else {
$favourites[] = $book_id;
}
update_user_meta( $user_id, 'gdc_books_favourites', array_map( 'absint', $favourites ) );
wp_send_json_success(
array(
'saved' => ! $is_saved,
'count' => count( $favourites ),
)
);
}
// Logged-in users only. Favourites belong to an account, so there is no nopriv hook.
add_action( 'wp_ajax_gdc_books_favourite', 'gdc_books_ajax_favourite' );wp_send_json_success() and wp_send_json_error() set the JSON content type, print {"success":true,"data":{...}} and stop execution — you never need a wp_die() after them.
// assets/js/favourites.js
document.addEventListener( 'click', async ( event ) => {
const button = event.target.closest( '[data-gdc-favourite]' );
if ( ! button ) {
return;
}
button.disabled = true;
const body = new URLSearchParams( {
action: 'gdc_books_favourite',
nonce: window.gdcBooks.nonce,
book_id: button.dataset.gdcFavourite,
} );
try {
const response = await fetch( window.gdcBooks.ajaxUrl, {
method: 'POST',
credentials: 'same-origin',
body,
} );
const json = await response.json();
if ( json.success ) {
button.setAttribute( 'aria-pressed', String( json.data.saved ) );
button.textContent = json.data.saved ? window.gdcBooks.i18n.saved : window.gdcBooks.i18n.removed;
}
} finally {
button.disabled = false;
}
} );And the button, printed by your template or shortcode:
<?php if ( is_user_logged_in() ) : ?>
<button type="button" class="gdc-favourite" aria-pressed="false"
data-gdc-favourite="<?php echo esc_attr( get_the_ID() ); ?>">
<?php esc_html_e( 'Favourite', 'gdc-books' ); ?>
</button>
<?php endif; ?>Register wp_ajax_nopriv_{action} only when logged-out visitors genuinely need the endpoint — a "load more books" button, for example. Anything there is reachable by anyone, so it must be read-only or strictly limited, and should never trust a user ID sent from the browser.
Tip: For new code, the REST API (next lesson) is usually the better choice: proper HTTP status codes, URL routes, schema validation and built-in authentication. admin-ajax.php remains common and perfectly valid for small, plugin-specific interactions.wp_ajax_ and wp_ajax_nopriv_ hooks.In accordance with Section 6 & Section 7 of the DPDP Act 2023, third-party analytics and marketing trackers are strictly gated behind your affirmative consent. You may withdraw consent at any time under Section 6(4).
Essential for session authentication (ci_session), Cross-Site Request Forgery security (csrf_cookie_name), and Cloudflare DDoS/bot prevention. Cannot be disabled.
Gated under DPDP Section 7. Allows Google Analytics 4 (Google Consent Mode v2) to measure aggregate, anonymized tool usage.
Enables browser local storage to preserve your theme selection (dark/light mode) and code editor layout between visits.
Used to display developer sponsorships and native tool partner notices.