REST API endpoints for AJAX requests
Chisel provides a REST API-based AJAX system that replaces WordPress’s legacy admin-ajax.php approach. All asynchronous requests use proper REST endpoints under the
chisel/v2/ajax/namespace with standardized JSON responses.
The AjaxController class automatically discovers and registers endpoint classes based on naming conventions. Core endpoints live incore/Ajax/, while project-specific endpoints belong incustom/app/Ajax/. Each endpoint implements a simplehandle()method that receives a request and returns a response.
Route registration uses a filter-based configuration. Define routes via thechisel_ajax_routesfilter, and the controller instantiates the corresponding endpoint class automatically. The Rest trait provides standardizedsuccess()anderror()response methods for consistent frontend integration.
Overview
Core controller
File: core/Controllers/AjaxController.php
The AjaxController extends WP_REST_Controller and handles:
- Route registration via
rest_api_inithook - Automatic endpoint class discovery and instantiation
- Nonce-based permission validation
- Standardized response formatting via the
Resttrait
ROUTE_NAMESPACE→'chisel/v2'ROUTE_BASE→'ajax'
Full endpoint URLs follow the pattern: /wp-json/chisel/v2/ajax/{route-name}/
Endpoint class discovery
The controller uses a naming convention to locate endpoint classes:
- Takes the route name (e.g.,
load-more) - Converts to PascalCase with
Endpointsuffix (e.g.,LoadMoreEndpoint) - Searches for the class in this order:
Directories:
REST trait
Provides three helper methods used by both the controller and endpoint classes:
Methods:
get_data( \WP_REST_Request $request ): array
Returns request body parameters via$request->get_body_params()success( mixed $data = array() ): \WP_REST_Response
Returns standardized success response with HTTP 200
{
"error": 0,
"message": "ok",
"data": { ... }
}JSONerror( string $message ): \WP_REST_Response
Returns standardized error response with HTTP 200
{
"error": 1,
"message": "Error description"
}JSONAll responses return HTTP 200 status with error flag (0 or 1) indicating success or failure.
Route registration
Default routes
File: core/Controllers/AjaxController.php
The controller includes core routes in its set_properties() method:
private array $routes = array(
'load-more' => array(),
);PHPRegistering custom routes
Add custom routes via filter in custom/app/WP/Ajax.php:
add_filter( 'chisel_ajax_routes', array( $this, 'register_ajax_routes' ) );
public function register_ajax_routes( array $routes ): array {
$routes['newsletter-subscribe'] = array(
'methods' => 'POST',
);
$routes['search-posts'] = array(
'methods' => 'GET',
);
// Custom handler (bypasses naming convention)
$routes['custom-action'] = array(
'methods' => 'POST',
'handler' => \Chisel\Ajax\Custom\MyCustomHandler::class,
);
return $routes;
}PHPRoute configuration options:
| Key | Type | Default | Description |
|---|---|---|---|
methods | string|array | 'POST' | HTTP methods ('GET', 'POST', ['GET', 'POST']) |
handler | string|null | null | Custom endpoint class (FQCN). Bypasses naming convention. |
Endpoint interface and implementation
Endpoint interface
File: core/Interfaces/AjaxEndpointInterface.php
All endpoint classes must implement:
interface AjaxEndpointInterface {
public function handle( \WP_REST_Request $request ): \WP_REST_Response;
}PHPCore endpoint example
File: core/Ajax/LoadMoreEndpoint.php
<?php
namespace Chisel\Ajax;
use Chisel\Interfaces\AjaxEndpointInterface;
use Chisel\Traits\Rest;
use Timber\Timber;
use Chisel\Helpers\CacheHelpers;
final class LoadMoreEndpoint implements AjaxEndpointInterface {
use Rest;
public function handle( \WP_REST_Request $request ): \WP_REST_Response {
if ( ! $request ) {
return $this->error( 'No request data' );
}
$data = $this->get_data( $request );
$post_type = isset( $data['post_type'] ) ? sanitize_text_field( $data['post_type'] ) : 'post';
$per_page = isset( $data['per_page'] ) ? absint( $data['per_page'] ) : 10;
$page = isset( $data['page'] ) ? absint( $data['page'] ) : 1;
$args = array(
'post_type' => $post_type,
'posts_per_page' => $per_page,
'paged' => $page,
);
if ( $post_type === 'product' ) {
$args['orderby'] = get_option( 'woocommerce_default_catalog_orderby', 'menu_order' );
}
$posts = Timber::get_posts( $args )->to_array();
$templates = array(
'components/' . $post_type . '-item.twig',
'components/post-item.twig'
);
if ( $post_type === 'product' ) {
array_unshift( $templates, 'woocommerce/content-product.twig' );
}
$response = '';
if ( ! empty( $posts ) ) {
foreach ( $posts as $post ) {
$response .= Timber::compile( $templates, array( 'post' => $post ), CacheHelpers::expiry() );
}
} else {
$response = Timber::compile( 'components/no-results.twig' );
}
return $this->success( $response );
}
}PHPCustom endpoint example
File: custom/app/Ajax/NewsletterSubscribeEndpoint.php
<?php
namespace Chisel\Ajax\Custom;
use Chisel\Interfaces\AjaxEndpointInterface;
use Chisel\Traits\Rest;
final class NewsletterSubscribeEndpoint implements AjaxEndpointInterface {
use Rest;
public function handle( \WP_REST_Request $request ): \WP_REST_Response {
$data = $this->get_data( $request );
$email = isset( $data['email'] ) ? sanitize_email( $data['email'] ) : '';
if ( ! is_email( $email ) ) {
return $this->error( __( 'Invalid email address.', 'chisel' ) );
}
// Custom newsletter logic here
// e.g., add to Mailchimp, send confirmation, etc.
return $this->success( array(
'message' => __( 'Thank you for subscribing!', 'chisel' ),
) );
}
}PHPSecurity and permissions
Nonce verification
Default behavior: All endpoints require a valid WordPress REST API nonce.
The controller’s permissions_check() method verifies the nonce from the x_wp_nonce header:
$verify_nonce = wp_verify_nonce( $request->get_header( 'x_wp_nonce' ), 'wp_rest' );PHPCustom permission checks
Filter: chisel_ajax_permissions_check
Override permissions for specific endpoints:
add_filter( 'chisel_ajax_permissions_check', array( $this, 'custom_ajax_permissions_check' ), 10, 3 );
public function custom_ajax_permissions_check( bool $allowed, string $endpoint_class, \WP_REST_Request $request ): bool {
// Allow public access to specific endpoint
if ( $endpoint_class === 'chisel-ajax-custom-newslettersubscribeendpoint' ) {
return true;
}
// Require admin capability for another endpoint
if ( $endpoint_class === 'chisel-ajax-custom-deletepostendpoint' ) {
return current_user_can( 'delete_posts' );
}
return $allowed;
}PHPNote: The $endpoint_class parameter is sanitized: namespace separators (\) are replaced with dashes (-), and the string is lowercased.
Frontend integration
Localized script data
The theme exposes REST API configuration to JavaScript via the chiselScripts global object:
window.chiselScripts = {
ajax: {
url: 'https://example.com/wp-json/chisel/v2/ajax',
nonce: 'abc123...'
}
};JavaScriptAjax helper utility
File: src/scripts/helpers/utils.js
The theme includes a built-in Utils class with an ajaxRequest() method that handles all AJAX calls:
class Utils {
ajaxRequest = async (action, ajaxData = {}, ajaxParams = {}, ajaxHeaders = {}) => {
const {
ajax: { url, nonce },
} = chiselScripts;
let formData;
if (ajaxData instanceof FormData) {
formData = ajaxData;
} else {
formData = new FormData();
Object.entries(ajaxData).forEach(([key, value]) => {
if (typeof value === 'object' && value !== null && !['file', 'files'].includes(key)) {
formData.append(key, JSON.stringify(value));
} else {
formData.append(key, value);
}
});
}
const params = {
method: 'POST',
headers: {
'X-WP-Nonce': nonce,
...ajaxHeaders,
},
credentials: 'same-origin',
...ajaxParams,
};
if (params.method === 'POST') {
params.body = formData;
}
const endpoint = `${url}/${action}`;
const response = await fetch(endpoint, params);
if (!response.ok) {
throw new Error('An error occurred');
}
const data = await response.json();
return data;
};
}
export default new Utils();JavaScriptFeatures:
- Automatically constructs full endpoint URL from action name
- Handles both plain objects and
FormDatainstances - Automatically adds
X-WP-Nonceheader for authentication - Serializes nested objects to JSON strings in FormData
- Uses
same-origincredentials for cookie-based authentication - Returns parsed JSON response
Usage examples
Import the helper:
import Utils from './helpers/utils';JavaScriptBasic AJAX call:
async function loadMorePosts(page) {
try {
const response = await Utils.ajaxRequest('load-more', {
post_type: 'post',
per_page: 10,
page: page,
});
if (response.error) {
console.error(response.message);
return null;
}
return response.data;
} catch (error) {
console.error('Request failed:', error);
}
}JavaScriptNewsletter subscription:
async function subscribeNewsletter(email) {
try {
const response = await Utils.ajaxRequest('newsletter-subscribe', {
email: email,
});
if (response.error) {
alert(response.message);
return false;
}
alert(response.data.message);
return true;
} catch (error) {
console.error('Subscription failed:', error);
return false;
}
}JavaScriptContact form with FormData:
const form = document.querySelector('#contact-form');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(form);
try {
const response = await Utils.ajaxRequest('contact-form', formData);
if (response.error) {
alert(response.message);
return;
}
alert(response.data.message);
form.reset();
} catch (error) {
console.error('Form submission failed:', error);
}
});JavaScriptFile upload:
const fileInput = document.querySelector('#file-upload');
fileInput.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const response = await Utils.ajaxRequest('upload-file', formData);
if (response.error) {
console.error(response.message);
return;
}
console.log('File uploaded:', response.data);
} catch (error) {
console.error('Upload failed:', error);
}
});JavaScriptCustom request parameters:
// GET request instead of POST
const response = await Utils.ajaxRequest(
'search-posts',
{ query: 'wordpress' },
{ method: 'GET' }
);
// Custom headers
const response = await Utils.ajaxRequest(
'custom-action',
{ data: 'value' },
{},
{ 'Custom-Header': 'value' }
);JavaScriptResponse handling
Always check the error flag in responses:
const response = await Utils.ajaxRequest('my-action', data);
if (response.error === 1) {
// Handle error
console.error(response.message);
} else {
// Success
const result = response.data;
}JavaScriptAction parameter
The action parameter in ajaxRequest() corresponds to the route name registered via chisel_ajax_routes filter:
| Action string | Full endpoint URL |
|---|---|
'load-more' | /wp-json/chisel/v2/ajax/load-more/ |
'newsletter-subscribe' | /wp-json/chisel/v2/ajax/newsletter-subscribe/ |
'contact-form' | /wp-json/chisel/v2/ajax/contact-form/ |
The helper automatically constructs the full URL by appending the action to chiselScripts.ajax.url
Endpoint naming convention
The controller converts route names to class names using this pattern:
| Route name | Class name | Namespace (custom) | Namespace (core) |
|---|---|---|---|
load-more | LoadMoreEndpoint | Chisel\Ajax\Custom\ | Chisel\Ajax\ |
newsletter-subscribe | NewsletterSubscribeEndpoint | Chisel\Ajax\Custom\ | Chisel\Ajax\ |
search-posts | SearchPostsEndpoint | Chisel\Ajax\Custom\ | Chisel\Ajax\ |
- Split route name by hyphens
- Capitalize each part
- Join parts together
- Append
Endpointsuffix
Adding a new endpoint
Step 1: Register the route
File: custom/app/WP/Ajax.php
add_filter( 'chisel_ajax_routes', array( $this, 'register_ajax_routes' ) );
public function register_ajax_routes( array $routes ): array {
$routes['contact-form'] = array(
'methods' => 'POST',
);
return $routes;
}PHPStep 2: Create the endpoint class
File: custom/app/Ajax/ContactFormEndpoint.php
<?php
namespace Chisel\Ajax\Custom;
use Chisel\Interfaces\AjaxEndpointInterface;
use Chisel\Traits\Rest;
final class ContactFormEndpoint implements AjaxEndpointInterface {
use Rest;
public function handle( \WP_REST_Request $request ): \WP_REST_Response {
$data = $this->get_data( $request );
$name = isset( $data['name'] ) ? sanitize_text_field( $data['name'] ) : '';
$email = isset( $data['email'] ) ? sanitize_email( $data['email'] ) : '';
$message = isset( $data['message'] ) ? sanitize_textarea_field( $data['message'] ) : '';
if ( empty( $name ) || empty( $email ) || empty( $message ) ) {
return $this->error( __( 'All fields are required.', 'chisel' ) );
}
if ( ! is_email( $email ) ) {
return $this->error( __( 'Invalid email address.', 'chisel' ) );
}
// Send email or save to database
wp_mail(
get_option( 'admin_email' ),
'Contact Form: ' . $name,
$message,
array( 'Reply-To: ' . $email )
);
return $this->success( array(
'message' => __( 'Thank you! Your message has been sent.', 'chisel' ),
) );
}
}PHPStep 3: Call from frontend
import Utils from './helpers/utils';
async function submitContactForm(formData) {
try {
const response = await Utils.ajaxRequest('contact-form', formData);
if (response.error) {
throw new Error(response.message);
}
return response.data;
} catch (error) {
console.error('Contact form error:', error);
}
}JavaScriptDebugging constants
When an AJAX endpoint is called, these constants are defined:
defined( 'DOING_AJAX' ) // true
defined( 'DOING_CHISEL_AJAX' ) // truePHPUse these to conditionally execute code only during AJAX requests:
if ( defined( 'DOING_CHISEL_AJAX' ) && DOING_CHISEL_AJAX ) {
// AJAX-specific logic
}PHPBest practices
- Use proper HTTP methods:
GETfor read operations,POSTfor mutations - Always sanitize input: Use WordPress sanitization functions (
sanitize_text_field(),sanitize_email(), etc.) - Return consistent responses: Always use
$this->success()or$this->error()from theResttrait - Validate early: Check for required parameters at the start of
handle()and return errors immediately - Use Timber for HTML: When returning markup, use
Timber::compile()with Twig templates instead of concatenating HTML strings - Keep endpoints focused: One endpoint = one action. Don’t create “do everything” endpoints
- Custom handlers for complex cases: Use the
handlerparameter in route config when you need to bypass naming conventions - Use the Utils helper: Always use
Utils.ajaxRequest()on the frontend for consistent nonce handling and response parsing