All articles

WordPress development

Shortcode plugins in Elementor: when and how to build them

By Rolando Escobar, WordPress developer for agencies · · 5 min read

Page builders make layout fast, but custom features built inside them are hard to test, reuse or move. An HTML widget full of inline JavaScript, or a widget tied to one builder’s API, ends up owned by a page instead of by the codebase.

I built the Orange County Dental Society site from scratch with WordPress blocks, from a Figma design. Features such as searches that filter content by tags are custom plugins, each placed on the page with a shortcode. The theme handles the design and the plugins handle the behavior, and the same split works in Elementor, where a shortcode drops into any layout. Here’s how to build that kind of plugin well, with Elementor as the example builder.

Shortcode or custom Elementor widget

Both are valid. The choice depends on who configures the feature and how long it has to live.

Shortcode in a plugin Custom Elementor widget
Works outside Elementor Yes: block editor, classic editor, widgets, PHP templates No
Testing A PHP function you can test with the WordPress test suite Needs Elementor loaded, harder to isolate
Editor experience Attributes typed by hand Visual controls with live preview
Styling Your CSS, which can read the kit’s global colors Elementor style controls per instance
Builder upgrades Unaffected Can break when the widget API changes

Choose a widget when editors will configure the feature often and need visual controls, such as a slider with per-slide settings. Choose a shortcode when the feature is mostly logic (a search, a filtered list, an integration), when it may outlive the current builder, or when you want tests around it.

My own portfolio shows that last point. It runs on Elementor 4’s atomic elements, and the older nav menu widget rendered badly inside the new containers, so the menu became an [rw_primary_nav] shortcode that wraps wp_nav_menu(). The builder changed and the shortcode didn’t care.

Register assets, don’t load them yet

Start the plugin by registering its CSS and JavaScript. Registering tells WordPress the files exist; it doesn’t print anything.

<?php
/**
 * Plugin Name: RW Resources
 * Description: Resource list shortcode: [rw_resources tag="" limit="6" layout="grid"].
 * Requires at least: 6.5
 * Requires PHP: 8.1
 * Text Domain: rw-resources
 */

defined( 'ABSPATH' ) || exit;

const RW_RESOURCES_VERSION = '1.2.0';

add_action( 'init', 'rw_resources_init' );

function rw_resources_init(): void {
	$url = plugin_dir_url( __FILE__ );

	// Register only. Nothing loads until the shortcode renders.
	wp_register_style( 'rw-resources', $url . 'assets/resources.css', [], RW_RESOURCES_VERSION );
	wp_register_script(
		'rw-resources',
		$url . 'assets/resources.js',
		[],
		RW_RESOURCES_VERSION,
		[ 'strategy' => 'defer', 'in_footer' => true ]
	);

	add_shortcode( 'rw_resources', 'rw_resources_shortcode' );
}

Registering on init instead of wp_enqueue_scripts means the handles exist in every context where the shortcode can run, including REST requests and the Elementor editor. The defer strategy, available since WordPress 6.3, keeps the script off the critical rendering path.

Write the callback

function rw_resources_shortcode( $atts ): string {
	$atts = shortcode_atts(
		[
			'tag'    => '',
			'limit'  => 6,
			'layout' => 'grid',
		],
		$atts,
		'rw_resources' // Enables the shortcode_atts_rw_resources filter.
	);

	// Attributes come from whoever edits the page: validate them like any input.
	$tag    = sanitize_title( $atts['tag'] );
	$limit  = min( max( absint( $atts['limit'] ), 1 ), 24 );
	$layout = in_array( $atts['layout'], [ 'grid', 'list' ], true ) ? $atts['layout'] : 'grid';

	// Load assets only on pages that render this shortcode.
	wp_enqueue_style( 'rw-resources' );
	wp_enqueue_script( 'rw-resources' );

	$cache_key = 'rw_res_' . md5( wp_json_encode( [ $tag, $limit, $layout, rw_resources_cache_version() ] ) );
	$html      = get_transient( $cache_key );

	if ( false === $html ) {
		$args = [
			'post_type'      => 'resource',
			'post_status'    => 'publish',
			'posts_per_page' => $limit,
			'no_found_rows'  => true,
		];
		if ( '' !== $tag ) {
			$args['tax_query'] = [
				[ 'taxonomy' => 'resource_tag', 'field' => 'slug', 'terms' => $tag ],
			];
		}

		$html = rw_resources_markup( new WP_Query( $args ), $layout );
		set_transient( $cache_key, $html, 12 * HOUR_IN_SECONDS );
	}

	return $html; // Return, never echo.
}

Four rules are at work here:

  • Pass the tag name to shortcode_atts(). The third argument enables the shortcode_atts_rw_resources filter, so other code can change defaults without editing yours.
  • Treat attributes as user input. Anyone who can edit a post can type a shortcode, contributors included. Clamp numbers, allowlist choices and sanitize slugs. Unescaped shortcode attributes are a frequent cause of stored XSS reports in WordPress plugins.
  • Return, never echo. WordPress puts the returned string where the shortcode was. Echoed output prints before the content it belongs to, which breaks the layout and, in Elementor, lands outside the widget’s wrapper.
  • Enqueue inside the callback. The assets load only on pages that render the shortcode. A script enqueued this late prints in the footer, which is why it was registered for the footer. A late stylesheet also prints in the footer, so keep that CSS small or accept a brief flash of unstyled content.

The Shortcodes chapter of the Plugin Handbook covers the API in more depth.

Escape at the point of output

function rw_resources_markup( WP_Query $query, string $layout ): string {
	if ( ! $query->have_posts() ) {
		return '<p class="rw-resources__empty">' . esc_html__( 'No resources found.', 'rw-resources' ) . '</p>';
	}

	$items = '';
	foreach ( $query->posts as $post ) {
		$items .= sprintf(
			'<li class="rw-resources__item"><a href="%1$s">%2$s</a><p>%3$s</p></li>',
			esc_url( get_permalink( $post ) ),
			esc_html( get_the_title( $post ) ),
			esc_html( get_the_excerpt( $post ) )
		);
	}

	return sprintf(
		'<ul class="rw-resources rw-resources--%1$s">%2$s</ul>',
		esc_attr( $layout ),
		$items
	);
}

Every dynamic value is escaped for its context: esc_url() for the link, esc_html() for text and esc_attr() for the class. Escape late, in the function that builds the HTML, so nothing depends on whether the data was clean earlier. The list is a real <ul>, which tells screen reader users how many items it holds, and each item has one link whose text is the resource title.

Cache the expensive part

A shortcode runs on every page view that isn’t served from a page cache. If it queries the database or calls an API, cache the result. The callback stores the HTML in a transient whose key includes the attributes and a version number. Bumping the version whenever a resource changes invalidates every cached variation at once:

function rw_resources_cache_version(): int {
	return (int) get_option( 'rw_resources_cache_version', 1 );
}

function rw_resources_bump_cache(): void {
	update_option( 'rw_resources_cache_version', rw_resources_cache_version() + 1, false );
}

// Saving, trashing or deleting a resource makes every cached list stale at once.
add_action( 'save_post_resource', 'rw_resources_bump_cache' );
add_action(
	'deleted_post',
	static function ( $post_id, $post ): void {
		// $post is a raw database row here, not a WP_Post, so don't type-hint it.
		if ( isset( $post->post_type ) && 'resource' === $post->post_type ) {
			rw_resources_bump_cache();
		}
	},
	10,
	2
);

Cache only output that’s the same for every visitor. Anything with a nonce, the current user’s name or cart contents must render fresh. Old transients expire on their own after 12 hours, and a persistent object cache, if the host provides one, keeps them in memory instead of the options table.

Elementor also has an Element Caching feature that stores a widget’s rendered HTML, with a Cache Settings option in each element’s Advanced tab. Elementor’s documentation says not to use it on elements that show dynamic information, such as shortcodes. There’s a second reason here: when cached HTML is served, your callback doesn’t run, so the enqueue inside it can’t run either. Turn element caching off for widgets that hold interactive shortcodes, and test the page after enabling any cache.

Test it like any PHP function

class Test_RW_Resources_Shortcode extends WP_UnitTestCase {

	public function set_up(): void {
		parent::set_up();
		wp_dequeue_script( 'rw-resources' );
	}

	public function test_limit_is_clamped(): void {
		self::factory()->post->create_many( 30, [ 'post_type' => 'resource' ] );

		$html = do_shortcode( '[rw_resources limit="500"]' );

		$this->assertSame( 24, substr_count( $html, '<li ' ) );
	}

	public function test_titles_are_escaped(): void {
		self::factory()->post->create( [ 'post_type' => 'resource', 'post_title' => '<b>Guide</b>' ] );

		$html = do_shortcode( '[rw_resources]' );

		$this->assertStringContainsString( '&lt;b&gt;Guide&lt;/b&gt;', $html );
	}

	public function test_assets_load_only_when_used(): void {
		$this->assertFalse( wp_script_is( 'rw-resources', 'enqueued' ) );

		do_shortcode( '[rw_resources]' );

		$this->assertTrue( wp_script_is( 'rw-resources', 'enqueued' ) );
	}
}

These tests run on the standard WordPress PHPUnit setup that wp scaffold plugin-tests generates. They cover what matters most: attributes are clamped, output is escaped and assets load only when the shortcode is used. None of them needs Elementor installed.

Using it inside an Elementor layout

  • Add a Shortcode widget to the container where the feature belongs and type [rw_resources tag="onboarding" limit="6"]. Width, spacing and the section heading stay in Elementor, where the designer expects them.
  • Style the plugin’s markup with CSS that falls back gracefully, and read Elementor’s global colors where they exist, for example color: var(--e-global-color-accent, #1d4ed8). Change the kit color and the feature follows.
  • In the editor, Elementor re-renders a widget over AJAX when you change it, so its script may not run in the canvas until you reload. Judge interactive behavior in Preview or on the front end.
  • Document the attributes in the plugin’s readme. When an attribute is invalid, you can show a short hint to editors only by checking current_user_can( 'edit_posts' ) before printing it.

What to watch out for

  • Prefix shortcode tags (rw_resources, not resources) so they don’t collide with other plugins.
  • Never run do_shortcode() on text that visitors submit from the front end.
  • Keep the wrapper markup lean. Elementor already wraps the shortcode in its own container.
  • Have the script find instances by class, not ID, so the shortcode can appear twice on a page.
  • If a feature needs many settings, move them to an options page instead of adding a tenth attribute.

This is the kind of work I take on for clients: Elementor development