How to build custom Elementor custom widgets extensions utilizing modern REST API Controllers schemas
Leveraging WordPress REST API Controllers for Advanced Elementor Widgets
Building custom Elementor widgets often involves fetching and displaying dynamic data. While traditional methods like `WP_Query` or direct database calls suffice for simpler scenarios, complex data interactions and real-time updates necessitate a more robust and scalable approach. This is where WordPress’s REST API Controllers shine. By abstracting data retrieval and manipulation behind a well-defined API, we can create highly decoupled and maintainable Elementor widget extensions that are also more testable and secure.
This guide focuses on constructing custom Elementor widgets that interact with custom REST API endpoints, specifically utilizing the modern Controller architecture introduced in WordPress 4.7. We’ll walk through setting up a custom endpoint, registering it, and then consuming it within an Elementor widget’s frontend rendering logic.
Defining a Custom REST API Endpoint with a Controller
The foundation of our extension will be a custom REST API endpoint. We’ll define this endpoint using a PHP class that extends `WP_REST_Controller`. This class will handle the registration of our routes and define the callback functions for different HTTP methods (GET, POST, etc.).
Let’s create a simple endpoint to fetch a list of custom post types (CPTs) and their associated metadata. For this example, assume we have a CPT registered as ‘book’.
Registering the Custom Post Type (for context)
Before we create the API endpoint, ensure your custom post type is registered. If you don’t have one, here’s a basic example:
<?php
/**
* Plugin Name: My Custom Elementor Extensions
* Description: Adds custom widgets and API endpoints for Elementor.
* Version: 1.0
* Author: Your Name
*/
function my_custom_elementor_extensions_register_cpt() {
$labels = array(
'name' => _x( 'Books', 'post type general name', 'my-elementor-extensions' ),
'singular_name' => _x( 'Book', 'post type singular name', 'my-elementor-extensions' ),
'menu_name' => _x( 'Books', 'admin menu', 'my-elementor-extensions' ),
'name_admin_bar' => _x( 'Book', 'add new button in admin bar', 'my-elementor-extensions' ),
'add_new' => _x( 'Add New', 'book', 'my-elementor-extensions' ),
'add_new_item' => __( 'Add New Book', 'my-elementor-extensions' ),
'edit_item' => __( 'Edit Book', 'my-elementor-extensions' ),
'view_item' => __( 'View Book', 'my-elementor-extensions' ),
'all_items' => __( 'All Books', 'my-elementor-extensions' ),
'search_items' => __( 'Search Books', 'my-elementor-extensions' ),
'parent_item_colon' => __( 'Parent Books:', 'my-elementor-extensions' ),
'not_found' => __( 'No books found.', 'my-elementor-extensions' ),
'not_found_in_trash' => __( 'No books found in Trash.', 'my-elementor-extensions' )
);
$args = array(
'labels' => $labels,
'public' => true,
'menu_position' => 5,
'supports' => array( 'title', 'editor', 'thumbnail', 'custom-fields' ),
'has_archive' => true,
'rewrite' => array( 'slug' => 'books' ),
'show_in_rest' => true, // Crucial for REST API access
'rest_base' => 'books',
'rest_controller_class' => 'WP_REST_Posts_Controller', // Default for posts
);
register_post_type( 'book', $args );
}
add_action( 'init', 'my_custom_elementor_extensions_register_cpt' );
Creating the API Controller Class
We’ll create a new PHP file, for instance, includes/class-my-api-controller.php, within our plugin structure.
<?php
/**
* Custom API Controller for Books.
*/
class My_API_Book_Controller extends WP_REST_Controller {
/**
* Namespace for the API.
*
* @var string
*/
protected $namespace = 'my-extensions/v1';
/**
* Register the routes.
*/
public function register_routes() {
// Route for getting a list of books
register_rest_route( $this->namespace, '/books', array(
array(
'methods' => WP_REST_Server::READABLE, // GET request
'callback' => array( $this, 'get_books' ),
'permission_callback' => array( $this, 'get_books_permissions_check' ),
'args' => $this->get_collection_params(),
),
) );
// Route for getting a single book
register_rest_route( $this->namespace, '/books/(?P<id>\d+)', array(
array(
'methods' => WP_REST_Server::READABLE, // GET request
'callback' => array( $this, 'get_book' ),
'permission_callback' => array( $this, 'get_book_permissions_check' ),
'args' => array(
'id' => array(
'description' => __( 'Unique identifier for the resource.', 'my-elementor-extensions' ),
'type' => 'integer',
'validate_callback' => 'rest_validate_request_arg',
),
),
),
) );
}
/**
* Retrieves books from the database.
*
* @param WP_REST_Request $request Full data about the request.
* @return WP_Error|WP_REST_Response Response object on success, or WP_Error object on failure.
*/
public function get_books( WP_REST_Request $request ) {
$args = $this->prepare_items_query( $request );
$query_args = array(
'post_type' => 'book',
'posts_per_page' => $args['per_page'],
'offset' => ( $args['page'] - 1 ) * $args['per_page'],
'orderby' => $args['orderby'],
'order' => $args['order'],
'post_status' => 'publish',
);
// Add search if provided
if ( ! empty( $args['search'] ) ) {
$query_args['s'] = $args['search'];
}
$books = new WP_Query( $query_args );
$data = array();
if ( $books->have_posts() ) {
foreach ( $books->posts as $post ) {
$data[] = $this->prepare_item_for_response( $post, $request );
}
} else {
return new WP_Error( 'no_books_found', __( 'No books found', 'my-elementor-extensions' ), array( 'status' => 404 ) );
}
// Prepare response with pagination
$response = new WP_REST_Response( $data );
$response->add_links( $this->prepare_links_for_collection( $request, $books ) );
return $response;
}
/**
* Retrieves a single book.
*
* @param WP_REST_Request $request Full data about the request.
* @return WP_Error|WP_REST_Response Response object on success, or WP_Error object on failure.
*/
public function get_book( WP_REST_Request $request ) {
$post_id = $request->get_param( 'id' );
$post = get_post( $post_id );
if ( ! $post || 'book' !== $post->post_type ) {
return new WP_Error( 'invalid_book_id', __( 'Invalid book ID', 'my-elementor-extensions' ), array( 'status' => 404 ) );
}
$data = $this->prepare_item_for_response( $post, $request );
$response = new WP_REST_Response( $data );
$response->add_links( $this->prepare_links_for_single( $post_id, $request ) );
return $response;
}
/**
* Prepares a single book output for the REST response.
*
* @param WP_Post $post Post object.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response Response object.
*/
public function prepare_item_for_response( $post, $request ) {
$data = array(
'id' => $post->ID,
'title' => get_the_title( $post ),
'content' => apply_filters( 'the_content', $post->post_content ),
'excerpt' => $post->post_excerpt,
'link' => get_permalink( $post ),
'featured_image' => get_the_post_thumbnail_url( $post, 'medium' ),
'meta' => array(),
);
// Fetch custom fields (meta)
$meta_fields = get_post_meta( $post->ID );
if ( ! empty( $meta_fields ) ) {
foreach ( $meta_fields as $key => $value ) {
// Decode if necessary, handle arrays
if ( is_array( $value ) && count( $value ) === 1 ) {
$data['meta'][ $key ] = maybe_unserialize( $value[0] );
} else {
$data['meta'][ $key ] = $value;
}
}
}
$response = new WP_REST_Response( $data );
$response->add_links( $this->prepare_links_for_single( $post->ID, $request ) );
return $response;
}
/**
* Prepares the collection parameters for the query.
*
* @param WP_REST_Request $request Full data about the request.
* @return array Query parameters.
*/
public function prepare_items_query( $request ) {
$params = $this->get_collection_params();
$query_params = array();
// Page number.
$page = isset( $params['page'] ) ? $params['page']['validate_callback']( $request->get_param( 'page' ), $request ) : 1;
if ( ! empty( $page ) ) {
$query_params['page'] = $page;
}
// Per page.
$per_page = isset( $params['per_page'] ) ? $params['per_page']['validate_callback']( $request->get_param( 'per_page' ), $request ) : 10;
if ( ! empty( $per_page ) ) {
$query_params['per_page'] = $per_page;
}
// Orderby.
$orderby = isset( $params['orderby'] ) ? $params['orderby']['validate_callback']( $request->get_param( 'orderby' ), $request ) : 'date';
if ( ! empty( $orderby ) ) {
$query_params['orderby'] = $orderby;
}
// Order.
$order = isset( $params['order'] ) ? $params['order']['validate_callback']( $request->get_param( 'order' ), $request ) : 'desc';
if ( ! empty( $order ) ) {
$query_params['order'] = $order;
}
// Search.
$search = isset( $params['search'] ) ? $params['search']['validate_callback']( $request->get_param( 'search' ), $request ) : '';
if ( ! empty( $search ) ) {
$query_params['search'] = $search;
}
return $query_params;
}
/**
* Retrieves the query parameters for the collection.
*
* @return array Collection parameters.
*/
public function get_collection_params() {
return array(
'page' => array(
'description' => __( 'Current page of the collection.', 'my-elementor-extensions' ),
'type' => 'integer',
'default' => 1,
'min' => 1,
'validate_callback' => 'rest_validate_request_arg',
),
'per_page' => array(
'description' => __( 'Maximum number of items to be returned in a result set.', 'my-elementor-extensions' ),
'type' => 'integer',
'default' => 10,
'min' => 1,
'max' => 100, // Limit per page for performance
'validate_callback' => 'rest_validate_request_arg',
),
'orderby' => array(
'description' => __( 'Sort collection by object attribute.', 'my-elementor-extensions' ),
'type' => 'string',
'default' => 'date',
'enum' => array( 'date', 'id', 'title', 'slug' ), // Allowed orderby fields
'validate_callback' => 'rest_validate_request_arg',
),
'order' => array(
'description' => __( 'Order sort attribute ascending or descending.', 'my-elementor-extensions' ),
'type' => 'string',
'default' => 'desc',
'enum' => array( 'asc', 'desc' ),
'validate_callback' => 'rest_validate_request_arg',
),
'search' => array(
'description' => __( 'Limit results in collection to those matching a string.', 'my-elementor-extensions' ),
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => 'rest_validate_request_arg',
),
);
}
/**
* Permission check for getting books.
*
* @param WP_REST_Request $request Full data about the request.
* @return bool|WP_Error True if the request has access, WP_Error object otherwise.
*/
public function get_books_permissions_check( $request ) {
// Example: Only allow logged-in users to view books.
// For public data, return true.
return current_user_can( 'read' );
}
/**
* Permission check for getting a single book.
*
* @param WP_REST_Request $request Full data about the request.
* @return bool|WP_Error True if the request has access, WP_Error object otherwise.
*/
public function get_book_permissions_check( $request ) {
// Similar permission check as above.
return $this->get_books_permissions_check( $request );
}
/**
* Prepares links for the collection.
*
* @param WP_REST_Request $request Request object.
* @param WP_Query $query The WP_Query object.
* @return array Links for the collection.
*/
protected function prepare_links_for_collection( $request, $query ) {
$base = sprintf( '/%s/%s/books', $this->namespace, 'v1' ); // Adjust namespace if needed
$links = array(
'self' => rest_url( $base ),
'collection' => rest_url( $base ),
);
$page = $request->get_param( 'page' );
if ( $page ) {
$links['self'] = add_query_arg( 'page', $page, $links['self'] );
}
$per_page = $request->get_param( 'per_page' );
if ( $per_page ) {
$links['self'] = add_query_arg( 'per_page', $per_page, $links['self'] );
}
// Add next/prev links
$total_items = $query->found_posts;
$total_pages = ceil( $total_items / ( $per_page ?: 10 ) );
if ( $page < $total_pages ) {
$next_page = $page + 1;
$links['next'] = add_query_arg( 'page', $next_page, rest_url( $base ) );
}
if ( $page > 1 ) {
$prev_page = $page - 1;
$links['prev'] = add_query_arg( 'page', $prev_page, rest_url( $base ) );
}
return $links;
}
/**
* Prepares links for a single item.
*
* @param int $post_id Post ID.
* @param WP_REST_Request $request Request object.
* @return array Links for the item.
*/
protected function prepare_links_for_single( $post_id, $request ) {
$base = sprintf( '/%s/%s/books', $this->namespace, 'v1' ); // Adjust namespace if needed
$links = array(
'self' => rest_url( sprintf( '%s/%d', $base, $post_id ) ),
'collection' => rest_url( $base ),
);
return $links;
}
}
Hooking into the REST API Registration
Now, we need to register an instance of our controller when the REST API is initialized. Add this to your main plugin file:
<?php
/**
* Register REST API routes.
*/
function my_custom_elementor_extensions_register_api_routes() {
$controller = new My_API_Book_Controller();
$controller->register_routes();
}
add_action( 'rest_api_init', 'my_custom_elementor_extensions_register_api_routes' );
With this in place, you can now access your books endpoint at your-site.com/wp-json/my-extensions/v1/books. You can test this using tools like Postman or `curl`.
curl -X GET "https://your-site.com/wp-json/my-extensions/v1/books?per_page=5&orderby=title&order=asc"
Building the Elementor Widget to Consume the API
Now, let’s create an Elementor widget that fetches data from our custom endpoint and displays it. We’ll assume you have a basic Elementor widget structure already set up. If not, refer to the official Elementor widget development guide.
We’ll focus on the render() method, which is responsible for outputting the widget’s HTML on the frontend.
Widget Class Structure
Create a new widget file, e.g., widgets/my-api-book-list.php.
<?php
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* Elementor API Book List Widget.
*/
class Elementor_API_Book_List_Widget extends \Elementor\Widget_Base {
/**
* Get widget name.
*
* Retrieve API Book List widget name.
*
* @since 1.0.0
* @access public
* @return string Widget name.
*/
public function get_name() {
return 'api-book-list';
}
/**
* Get widget title.
*
* Retrieve API Book List widget title.
*
* @since 1.0.0
* @access public
* @return string Widget title.
*/
public function get_title() {
return __( 'API Book List', 'my-elementor-extensions' );
}
/**
* Get widget icon.
*
* Retrieve API Book List widget icon.
*
* @since 1.0.0
* @access public
* @return string Widget icon.
*/
public function get_icon() {
return 'eicon-list-bulleted'; // Choose an appropriate Elementor icon
}
/**
* Get widget categories.
*
* Retrieve the list of categories the API Book List widget belongs to.
*
* @since 1.0.0
* @access public
* @return array Widget categories.
*/
public function get_categories() {
return [ 'my-custom-widgets' ]; // Define your custom category if needed
}
/**
* Register widget controls.
*
* Add input fields to allow the user to customize the widget settings.
*
* @since 1.0.0
* @access protected
*/
protected function _register_controls() {
$this->start_controls_section(
'section_content',
[
'label' => __( 'Content', 'my-elementor-extensions' ),
'tab' => \Elementor\Controls_Manager::TAB_CONTENT,
]
);
$this->add_control(
'api_endpoint',
[
'label' => __( 'API Endpoint URL', 'my-elementor-extensions' ),
'type' => \Elementor\Controls_Manager::TEXT,
'default' => rest_url( 'my-extensions/v1/books' ), // Default to our custom endpoint
'description' => __( 'Enter the full URL of the REST API endpoint to fetch data from.', 'my-elementor-extensions' ),
'label_block' => true,
]
);
$this->add_control(
'items_per_page',
[
'label' => __( 'Items Per Page', 'my-elementor-extensions' ),
'type' => \Elementor\Controls_Manager::NUMBER,
'default' => 5,
'min' => 1,
'max' => 50,
'description' => __( 'Number of items to display per page.', 'my-elementor-extensions' ),
]
);
$this->add_control(
'orderby',
[
'label' => __( 'Order By', 'my-elementor-extensions' ),
'type' => \Elementor\Controls_Manager::SELECT,
'options' => [
'date' => __( 'Date', 'my-elementor-extensions' ),
'id' => __( 'ID', 'my-elementor-extensions' ),
'title' => __( 'Title', 'my-elementor-extensions' ),
'slug' => __( 'Slug', 'my-elementor-extensions' ),
],
'default' => 'date',
]
);
$this->add_control(
'order',
[
'label' => __( 'Order', 'my-elementor-extensions' ),
'type' => \Elementor\Controls_Manager::SELECT,
'options' => [
'asc' => __( 'Ascending', 'my-elementor-extensions' ),
'desc' => __( 'Descending', 'my-elementor-extensions' ),
],
'default' => 'desc',
]
);
$this->end_controls_section();
// Add styling controls here if needed
}
/**
* Render widget output on the frontend.
*
* Written in PHP and used to generate the final HTML.
*
* @since 1.0.0
* @access protected
*/
protected function render() {
$api_endpoint = $this->get_settings( 'api_endpoint' );
$items_per_page = $this->get_settings( 'items_per_page' );
$orderby = $this->get_settings( 'orderby' );
$order = $this->get_settings( 'order' );
if ( empty( $api_endpoint ) ) {
echo '<p>' . __( 'API endpoint URL is not configured.', 'my-elementor-extensions' ) . '</p>';
return;
}
// Construct the full API URL with parameters
$api_url = add_query_arg( array(
'per_page' => $items_per_page,
'orderby' => $orderby,
'order' => $order,
), $api_endpoint );
// Fetch data from the API
$response = wp_remote_get( $api_url );
if ( is_wp_error( $response ) ) {
echo '<p>' . sprintf( __( 'Error fetching data: %s', 'my-elementor-extensions' ), $response->get_error_message() ) . '</p>';
return;
}
$body = wp_remote_retrieve_body( $response );
$data = json_decode( $body, true );
if ( ! $data || json_last_error() !== JSON_ERROR_NONE ) {
echo '<p>' . __( 'Error decoding API response.', 'my-elementor-extensions' ) . '</p>';
return;
}
// Check if the response is an error from our controller
if ( isset( $data['code'] ) && $data['code'] !== 'rest_ok' ) {
echo '<p>' . sprintf( __( 'API Error: %s - %s', 'my-elementor-extensions' ), $data['code'], $data['message'] ) . '</p>';
return;
}
// Render the list of books
if ( ! empty( $data ) ) {
echo '<ul class="api-book-list">';
foreach ( $data as $book ) {
// Ensure essential keys exist before accessing
$title = isset( $book['title'] ) ? esc_html( $book['title'] ) : __( 'Untitled Book', 'my-elementor-extensions' );
$link = isset( $book['link'] ) ? esc_url( $book['link'] ) : '#';
$excerpt = isset( $book['excerpt'] ) ? wp_kses_post( $book['excerpt'] ) : '';
$featured_image = isset( $book['featured_image'] ) ? '
' : '';
echo '<li class="api-book-item">';
if ( ! empty( $featured_image ) ) {
echo '<div class="book-thumbnail">' . $featured_image . '</div>';
}
echo '<h3><a href="' . $link . '">' . $title . '</a></h3>';
if ( ! empty( $excerpt ) ) {
echo '<div class="book-excerpt">' . $excerpt . '</div>';
}
// Display custom meta if available
if ( ! empty( $book['meta'] ) ) {
echo '<div class="book-meta">';
foreach ( $book['meta'] as $meta_key => $meta_value ) {
// Basic sanitization for meta display
$display_key = ucwords( str_replace( '_', ' ', $meta_key ) );
if ( is_string( $meta_value ) ) {
echo '<p><strong>' . esc_html( $display_key ) . ':</strong> ' . esc_html( $meta_value ) . '</p>';
} elseif ( is_array( $meta_value ) ) {
echo '<p><strong>' . esc_html( $display_key ) . ':</strong> ' . implode( ', ', array_map( 'esc_html', $meta_value ) ) . '</p>';
}
}
echo '</div>';
}
echo '</li>';
}
echo '</ul>';
} else {
echo '<p>' . __( 'No books found.', 'my-elementor-extensions' ) . '</p>';
}
}
/**
* Render widget output in the editor.
*
* Written as a JavaScript template and used to generate the live preview.
*
* @since 1.0.0
* @access protected
*/
protected function _content_template() {
?>
<#
var api_endpoint = settings.api_endpoint;
var items_per_page = settings.items_per_page;
var orderby = settings.orderby;
var order = settings.order;
if ( ! api_endpoint ) {
print( '' + elementor.translate( 'API endpoint URL is not configured.' ) + '
' );
return;
}
// In the editor, we can't directly fetch from the API in PHP.
// We'll display a placeholder or a message indicating data will load.
// For live preview, Elementor's AJAX will handle fetching.
#>
<# print( api_endpoint ); #>
<# print( items_per_page ); #>
<# print( orderby ); #> (<# print( order ); #>)