Jump to Navigation Jump to Main Content Jump to Footer
Home » Docs » Features » REST API endpoints for AJAX requests

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 in core/Ajax/, while project-specific endpoints belong in custom/app/Ajax/. Each endpoint implements a simple handle() method that receives a request and returns a response.

Route registration uses a filter-based configuration. Define routes via the chisel_ajax_routes filter, and the controller instantiates the corresponding endpoint class automatically. The Rest trait provides standardized success() and error() 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_init hook
  • Automatic endpoint class discovery and instantiation
  • Nonce-based permission validation
  • Standardized response formatting via the Rest trait

Constants:

  • 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:

  1. Takes the route name (e.g., load-more)
  2. Converts to PascalCase with Endpoint suffix (e.g., LoadMoreEndpoint)
  3. Searches for the class in this order:
    • Custom handler class specified in route config (via handler parameter)
    • Chisel\Ajax\Custom\LoadMoreEndpoint (custom endpoints)
    • Chisel\Ajax\LoadMoreEndpoint (core endpoints)

Directories:

  • Core endpoints: core/Ajax/
  • Custom endpoints: custom/app/Ajax/

REST trait

File: core/Traits/Rest.php

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": { ... }
}
JSON
  • error( string $message ): \WP_REST_Response
    Returns standardized error response with HTTP 200
{
  "error": 1,
  "message": "Error description"
}
JSON

All 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(),
);
PHP

Registering custom routes

Filter: chisel_ajax_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;
}
PHP

Route configuration options:

KeyTypeDefaultDescription
methodsstring|array'POST'HTTP methods ('GET', 'POST', ['GET', 'POST'])
handlerstring|nullnullCustom 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;
}
PHP

Core 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 );
    }
}
PHP

Custom 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' ),
        ) );
    }
}
PHP

Security 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' );
PHP

Custom 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;
}
PHP

Note: 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...'
    }
};
JavaScript

Ajax 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();
JavaScript

Features:

  • Automatically constructs full endpoint URL from action name
  • Handles both plain objects and FormData instances
  • Automatically adds X-WP-Nonce header for authentication
  • Serializes nested objects to JSON strings in FormData
  • Uses same-origin credentials for cookie-based authentication
  • Returns parsed JSON response

Usage examples

Import the helper:

import Utils from './helpers/utils';
JavaScript

Basic 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);
  }
}
JavaScript

Newsletter 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;
  }
}
JavaScript

Contact 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);
  }
});
JavaScript

File 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);
  }
});
JavaScript

Custom 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' }
);
JavaScript

Response 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;
}
JavaScript

Action parameter

The action parameter in ajaxRequest() corresponds to the route name registered via chisel_ajax_routes filter:

Action stringFull 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 nameClass nameNamespace (custom)Namespace (core)
load-moreLoadMoreEndpointChisel\Ajax\Custom\Chisel\Ajax\
newsletter-subscribeNewsletterSubscribeEndpointChisel\Ajax\Custom\Chisel\Ajax\
search-postsSearchPostsEndpointChisel\Ajax\Custom\Chisel\Ajax\

Conversion rules:

  1. Split route name by hyphens
  2. Capitalize each part
  3. Join parts together
  4. Append Endpoint suffix

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;
}
PHP

Step 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' ),
        ) );
    }
}
PHP

Step 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);
    }
}
JavaScript

Debugging constants

When an AJAX endpoint is called, these constants are defined:

defined( 'DOING_AJAX' )        // true
defined( 'DOING_CHISEL_AJAX' ) // true
PHP

Use these to conditionally execute code only during AJAX requests:

if ( defined( 'DOING_CHISEL_AJAX' ) && DOING_CHISEL_AJAX ) {
    // AJAX-specific logic
}
PHP

Best practices

  • Use proper HTTP methods: GET for read operations, POST for 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 the Rest trait
  • 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 handler parameter 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

Do you like Chisel?

Give it a star on GitHub!