Shortcodes: Create Page-Builder Blocks

A shortcode is the bridge between the visual editor and your PHP: editors drag a block, fill a small form, and the page stores a tag like <x-shortcode-testimonials limit="6"></x-shortcode-testimonials> that your Blade component renders with live data on every request.

How it works

PHP                                        Editor (GrapesJS)                       Public site
ShortcodeContract::definition()  ---->  block + component type + settings form
  id, name, tag, category, icon,          drag onto page, edit traits   ---->  saved HTML contains
  defaults, traits, assets, ai              live preview from server           <x-shortcode-ID attr="..."></...>
Shortcode::registerIfMissing(Class)                                                       |
Blade::component('shortcode-ID', Class) <--------------------------------------------- Blade::render(content)
Component::render() -> theme/module view                                  assets of tags present are injected
  1. Your class implements App\Services\PageBuilder\Contracts\ShortcodeContract: a static id() and a static definition(): ShortcodeDefinition.
  2. The provider registers it (Shortcode::registerIfMissing(MyShortcode::class)). The editor page embeds ShortcodeRegistry::all() as JSON.
  3. The editor creates a block and a component type per definition; each trait becomes a field in the settings panel; the canvas shows a live preview.
  4. The page stores the tag with the chosen attributes. PageSlugRenderer compiles the saved content with Blade::render(), so the component class runs with current data.

A complete, tested example is in Tutorial step 8 (Testimonials block). This page is the reference.

The definition

final class NoteShortcode implements ShortcodeContract
{
    public static function id(): string { return 'playground-note'; }        // unique, kebab-case, module-prefixed

    public static function definition(): ShortcodeDefinition
    {
        return ShortcodeDefinition::make(
            id: self::id(),
            name: __('playground::p.note'),               // block label
            tag: 'x-shortcode-playground-note',           // Blade tag; convention: x-shortcode-{id}
            category: 'playground',                       // block-panel group
            priority: 20,                                 // order inside the group
        )
            ->icon('<i class="ph ph-note" aria-hidden="true"></i>')   // HTML or inline SVG
            ->description('Shows one note.')
            ->defaults(['note' => ''])                    // attributes written on a freshly dropped block
            ->aliases(['x-playground-note'])              // extra tags this definition also owns (assets are matched for them too)
            ->assets([...])                               // see "Assets"
            ->ai(['enabled' => true, 'module' => 'playground'])
            ->traits([ /* the settings form */ ]);
    }
}
ShortcodeDefinition methodPurpose
make($id, $name, $tag, $category = 'shortcodes', $priority = 100)Create. The preview URL (admin.page-builder.shortcodes.preview) is derived from $id
icon(?string), description(?string)Block panel presentation
defaults(array)Initial attribute values
traits(array) / trait($t)The settings form (below)
wrapper(?string)Optional wrapper hint for the editor
assets(array)Assets to load when the tag is on a page
aliases(array)Additional tags handled by the same definition
ai(array)Opt in to the AI assistant: ['enabled' => true, 'module' => 'alias']
enabled(bool) / disabled()Hide a definition without unregistering it
category(), priority()Change after creation

Traits (the settings form)

Each trait is created with XTrait::make($attributeName, $label) and writes to the attribute of that name on the tag.

TraitControlSpecific methods
TextTraitText input—
TextareaTraitMultilinerows(int)
NumberTraitNumbermin(), max(), step()
SelectTraitFixed dropdownoptions([['id' => 'cards', 'name' => 'Cards'], ...])
AsyncSelectTraitSearch-as-you-type dropdownprovider('notes'), multiple(), minimumInputLength(n)
CheckboxTraitCheckboxvalues('true', 'false') — the strings written for on/off
ColorTraitColour picker—
ImageTraitImage pickermediaLibrary(bool)
HiddenTraitNo UI, carries a value—

All traits share: placeholder(), default(), required() (the preview waits until required fields are filled), hint(), changeProp() (property vs attribute) and append(array) for raw editor options.

Dynamic option lists

Implement HasShortcodeOptions on the same class. An AsyncSelectTrait with ->provider('notes') gets an options URL; the editor calls it while the user types and your options() returns [['id' => '1', 'name' => 'Alpha'], ...]:

public function options(string $field, array $context = []): array
{
    if ($field !== 'notes') {
        return [];
    }

    return Note::query()
        ->when(filled($context['q'] ?? null), fn ($q) => $q->where('title', 'like', '%' . $context['q'] . '%'))
        ->limit(20)->get()
        ->map(fn (Note $note): array => ['id' => (string) $note->id, 'name' => $note->title])
        ->all();
}
// GET /admin/page-builder/shortcodes/playground-note/options/notes?q=Alp   (admin session, route name admin.page-builder.shortcodes.options)

Cap results (20–50) and match on the translated name with whereTranslationLike() for translatable models. This endpoint runs inside the admin group (auth, verified, 2fa) but has no extra permission; expose only data any page editor may see.

The Blade component

class NoteBlock extends Component
{
    public ?Note $record;

    // Tag attributes arrive as STRINGS. Hyphenated names become camelCase arguments:
    //   note="3" heading="Hi" show-rating="true"   =>  __construct($note, $heading, $showRating)
    public function __construct(public string $note = '', public string $heading = '')
    {
        $this->record = $note !== '' ? Note::find((int) $note) : null;
    }

    public function render(): View
    {
        return view('playground::components.note-block');
    }
}

// Provider boot(): make the tag resolvable
Blade::component('shortcode-playground-note', NoteBlock::class);

Deactivating the plugin breaks every shortcode on pages that use it

If a saved page still contains a tag whose component no longer exists, Blade::render() throws. PageSlugRenderer swallows the exception and prints the stored HTML raw, so a valid shortcode placed beside one orphaned tag stops rendering too. Document this for site owners; consider registering a tiny fallback component that renders nothing when a plugin is unloaded.

Live preview

The editor loads GET admin.page-builder.shortcodes.preview?attributes[...] and shows the returned HTML in the canvas. By default the server renders the tag itself (<x-shortcode-id attr="..."> through Blade). Implement HasShortcodePreview::preview(array $attributes): string for a cheaper or sandboxed preview.

Assets

->assets([
    'bundle' => 'playground.note',          // de-duplication group name
    'priority' => 50,                       // ascending; lower loads first
    'vite' => ['Modules/Playground/resources/js/front/note.js'],      // needs a Vite entry in the root config
    'styles' => ['playground-note' => '/modules/playground/note.css'], // key => url (or ['url' => ..., 'attributes' => [...]])
    'scripts' => ['playground-note' => ['url' => '/modules/playground/note.js', 'attributes' => ['defer' => true]]],
])

Registry API (App\Facades\Shortcode)

CallPurpose
category($id, $label, $priority = 100)Block-panel group. Priority scale shared with the editor: 10–29 editor basics, 30–99 module categories (Booking 30, TruCommerce several categories in that range), 100+ everything else (core content 200, forms 210)
registerIfMissing($class)Register unless the id is taken. Use this
register($class)Register; throws InvalidArgumentException if the id exists
replace($class)Override an existing id (replace a core block with yours)
group($shortcodeId, $category)Move a registered shortcode to a category
has($id), get($id), handler($id), all(), grouped(), categories()Introspection (get() returns the definition array)
registerMarker($marker, $assets, $id), registerContentAssets($content)Asset matching (above)
definitionForTag($tag)Find a definition by tag or alias

Security and quality checklist

  1. Treat every attribute as untrusted text; escape on output; validate/clamp on input.
  2. Bound all queries (limits) and eager-load; a page may contain several blocks.
  3. Do not read request input in a component unless the block is deliberately interactive (and then opt out of the response cache).
  4. Provide sensible defaults so a freshly dropped block renders something meaningful.
  5. Translate labels (__('alias::...')) and keep ids stable once released: saved pages contain them.
  6. Write a Pest test that renders the tag with Blade::render('<x-shortcode-id ... />') and asserts the registry contains the definition (see the tutorial test file).