Subtle diagonal pinstripe pattern in light grey and cream, giving an impression of printed editorial paper

Build a custom WordPress shortcode with attributes and defaults

A shortcode lets editors reuse a piece of WordPress content without editing a theme template. In this example, an editor can choose how many articles appear, which category they come from, and whether dates and excerpts are shown. Those choices become shortcode attributes, while the plugin supplies defaults that keep the block useful when nobody changes anything.

The example uses standard WordPress posts, so it works with any theme and includes the validation and escaping required for public pages. It also reflects the Australian context: teams in Sydney and Melbourne commonly maintain staging sites, site times follow local daylight-saving rules, and privacy obligations can shape what a plugin is allowed to collect or display.

Register the shortcode in a plugin

Keep the implementation in a plugin rather than a theme’s functions.php. A small plugin survives theme changes, can be version-controlled and has one obvious activation switch. Create a plugin folder, add a PHP file with the code below, and activate it from Plugins in WordPress. The defined check prevents direct file access, while add_shortcode connects the [post_cards] tag to its callback.

<?php
/**
 * Plugin Name: Acme Post Cards
 * Description: Displays a configurable grid of WordPress posts.
 * Version: 1.0.0
 */

defined( 'ABSPATH' ) || exit;

function acme_post_cards_shortcode( $atts ) {
    $atts = shortcode_atts(
        array(
            'posts_per_page' => 3,
            'category'       => 'news',
            'show_date'      => true,
            'show_excerpt'   => false,
        ),
        $atts,
        'post_cards'
    );

    $count = absint( $atts['posts_per_page'] );
    $atts['posts_per_page'] = $count > 0 ? min( 12, $count ) : 3;
    $atts['show_date']    = wp_validate_boolean( $atts['show_date'] );
    $atts['show_excerpt'] = wp_validate_boolean( $atts['show_excerpt'] );
    $atts['category']     = sanitize_title( $atts['category'] );

    $query = new WP_Query(
        array(
            'post_type'           => 'post',
            'post_status'         => 'publish',
            'posts_per_page'      => $atts['posts_per_page'],
            'category_name'       => $atts['category'],
            'ignore_sticky_posts' => true,
            'no_found_rows'       => true,
        )
    );

    if ( ! $query->have_posts() ) {
        return '<p class="acme-post-cards-empty">' .
            esc_html__( 'No posts are available.', 'acme-post-cards' ) .
            '</p>';
    }

    $output = '<div class="acme-post-cards">';

    while ( $query->have_posts() ) {
        $query->the_post();

        $output .= '<article class="acme-post-card">';
        $output .= '<h3 class="acme-post-card__title">';
        $output .= '<a href="' . esc_url( get_permalink() ) . '">';
        $output .= esc_html( get_the_title() );
        $output .= '</a></h3>';

        if ( $atts['show_date'] ) {
            $output .= '<time class="acme-post-card__date" datetime="' .
                esc_attr( get_the_date( 'c' ) ) . '">' .
                esc_html( get_the_date( 'j F Y' ) ) . '</time>';
        }

        if ( $atts['show_excerpt'] && ! post_password_required() ) {
            $source   = has_excerpt() ? get_the_excerpt() : get_the_content();
            $excerpt  = wp_trim_words( wp_strip_all_tags( $source ), 25 );
            $output  .= '<p class="acme-post-card__excerpt">' .
                esc_html( $excerpt ) . '</p>';
        }

        $output .= '</article>';
    }

    $output .= '</div>';
    wp_reset_postdata();

    return $output;
}
add_shortcode( 'post_cards', 'acme_post_cards_shortcode' );

Give the callback a prefix such as acme_ to reduce the chance of a name collision with another plugin. The shortcode name itself can also be changed, but update every saved post if you rename it. The callback returns HTML; it should not print the markup directly, because buffering and timing problems can otherwise leave broken output inside the surrounding article.

Define attributes and sensible defaults

Attributes are declared in one array so the supported options and their fallback values live together. shortcode_atts fills in missing values and ignores attributes that have not been declared. This makes [post_cards] equivalent to using the declared values, while an editor can override only the settings that matter for a particular placement.

[post_cards]
[post_cards posts_per_page="5" category="guides" show_excerpt="true"]
[post_cards category="" show_date="false"]

The first example shows three posts from the default category, with a date but no excerpt. The second requests five posts from the guides category and adds an excerpt. The third deliberately selects every category and hides the date. An empty category works because category_name treats an empty value as no category restriction.

Use absint to turn the count into a non-negative integer, then cap it at twelve to keep a copied attribute from creating an unexpectedly heavy query. A zero count returns the fallback of three. Boolean values pass through wp_validate_boolean, which recognises strings such as "false", "0" and "no" as false instead of treating every non-empty string as true.

Attribute names should describe the result rather than implementation details. If the block later gains a layout option, add it to the defaults array and validate it in the same callback. Keep the shortcode name stable where possible, since editors often rely on it inside reusable blocks and page-builder templates across many Australian client sites.

Query posts and render accessible cards

The query uses WP_Query with published posts, which keeps category filtering and date ordering inside WordPress’s prepared query layer. no_found_rows avoids an extra count that a card grid does not need, and ignore_sticky_posts prevents one sticky article from disrupting the requested order. The rendering code escapes each title, URL and date at the point it enters the HTML.

WordPress’s category_name option expects a category slug, such as product-reviews. If the category does not exist, the query returns no cards, and the callback displays the empty message. Excerpts use the editor’s manual summary when one exists; otherwise, the plugin removes markup and trims the content to twenty-five words.

Reset the post data with wp_reset_postdata after the loop. Without it, later template code may still see the last card as the current post, which can affect menus, breadcrumbs or a following shortcode. The same shortcode can appear twice on a page because each call creates its own query and output. Its wrapper, article elements and heading also provide a useful structure for visitors using assistive technology.

Secure the output and control access

Shortcode attributes are editor-supplied input, even though the final page is public. Sanitising them removes unsafe characters; escaping the resulting HTML prevents a title or excerpt from injecting markup. These are separate jobs: sanitize_text_field is not a substitute for esc_html, and neither replaces careful permission checks around who may publish content.

Keep post_status fixed to publish rather than accepting it as an attribute. Do not add an attribute that exposes drafts, private posts or another user’s personal information. A shortcode is a display mechanism, not an authorisation system. Restrict plugin editing to trusted roles, and review any extension that changes the query, especially if the site collects customer enquiries.

Password-protected posts need special care because their excerpts may still be stored in the database. The example skips excerpt output when a password is required and shows only the title and link. For a directory of members, add a capability check or a private query in a purpose-built extension rather than relying on an obscure URL. A stable shortcode can remain public while sensitive content is handled by a separate, authorised system.

Add scoped styles and load assets

Return semantic markup with a wrapper, articles and headings, then let a theme or a small stylesheet handle presentation. The class names below are deliberately scoped to the shortcode, which reduces interference with the active theme. Add this loader to the plugin and create assets/post-cards.css in the plugin’s assets folder.

add_action( 'wp_enqueue_scripts', 'acme_post_cards_assets' );

function acme_post_cards_assets() {
    wp_enqueue_style(
        'acme-post-cards',
        plugins_url( 'assets/post-cards.css', __FILE__ ),
        array(),
        '1.0.0'
    );
}
.acme-post-cards {
    display: grid;
    gap: 1.5rem;
    grid-template-columns: repeat( auto-fit, minmax( 16rem, 1fr ) );
}

.acme-post-card {
    padding: 1.25rem;
    border: 1px solid #d8d8d8;
    background: #fff;
}

.acme-post-card__title {
    margin-top: 0;
    font-size: 1.25rem;
}

.acme-post-card__date {
    display: block;
    color: #444;
    font-size: .875rem;
}

.acme-post-card__excerpt {
    margin-bottom: 0;
}

@media ( max-width: 40rem ) {
    .acme-post-cards {
        grid-template-columns: 1fr;
    }
}

This lightweight stylesheet loads on every front-end request, keeping its timing simple. For a larger file, detect the shortcode with has_shortcode() where appropriate or enqueue it from the shortcode callback, while testing reusable blocks, widgets and cached pages. Version the asset and change that version whenever its rules change; otherwise a browser or CDN may keep serving an older file. Check focus states, contrast and narrow layouts so the cards remain usable on phones, while reusing the active theme’s existing card styles when possible.

Test and deploy across Australian sites

Test the shortcode with no attributes, a valid count, a zero count, an invalid category, and a category containing an apostrophe in its name. Insert it with the Shortcode block rather than a Code block, since a Code block deliberately displays its contents as literal text. Confirm that the page still shows the site header, footer and accessibility tools in the expected order.

On a staging site, publish and unpublish a post, then check the relevant archive. Pages with a dynamic query may be cached by the host, a page builder or a CDN, so a changed card list can remain stale until the cache is purged. If you add caching inside the plugin, invalidate the relevant entry when a post is published or updated instead of rebuilding every request. Many agencies in Sydney, Melbourne and Brisbane use a managed WordPress host, so confirm that the plugin works with its supported PHP version and cache configuration.

Australian deployments require several local checks. Configure WordPress with the intended site time zone: Sydney and Melbourne alternate between AEST and AEDT, while Perth remains on ACST. A .com.au domain normally requires Australian presence and registration through an approved registrar, with an ABN or ACN commonly supplied during eligibility checks. WooCommerce stores commonly display prices in AUD and must make GST-inclusive or GST-exclusive pricing clear; a separate product teaser should not apply tax for a second time.

If the plugin stores enquiries or member details, review the Privacy Act 1988 and the Australian Privacy Principles, where they apply, alongside relevant state privacy rules for health information. Marketing statements and automatic-renewal notices must also respect Australian Consumer Law. Test the release with database backups and a deployment log. Macros and shortcuts, including a Mac keyboard workflow utility, can speed repetitive admin tasks for a local development team, but the website must not depend on them.