Skip to content

Widget Anatomy

This page describes the structure of a Pagebuilder widget in detail: directory layout, PHP class, required methods, and how it interacts with the plugin bootstrap and the Pagebuilder runtime.

Directory structure

A widget always lives under the widgets/ directory of its plugin:

_public/extensions/core/backend/{plugin}/
├── bootstrap.php                 # Plugin bootstrap (registers widgets via $this->pagebuilder_widgets)
└── widgets/
    └── {widget_template}/
        ├── bootstrap.php         # Widget class {widget_template}_PageBuilderPlugin
        ├── icon.png              # Widget icon for the picker (64x64 or 128x128 PNG recommended)
        └── template/
            └── index.vue         # Frontend Vue component

Folder name = widget_template

The widget subdirectory name must match exactly the widget_template value from the JSON in $this->pagebuilder_widgets. The class name too ({widget_template}_PageBuilderPlugin). Any deviation: the widget fails to load, and the picker does not list it.

The _PageBuilderPlugin class

Unlike admin plugins, the widget class inherits from nothing — it only has three static methods:

php
<?php
// _public/extensions/core/backend/{plugin}/widgets/{widget_template}/bootstrap.php

class myWidget_PageBuilderPlugin
{
    public static function getVersion(): string
    {
        return "1.0.0";
    }

    public static function getName(): string
    {
        return "My Widget";
    }

    public static function getWidgetContent($widget_array, $backend = false)
    {
        // null → no data load (pure form-driven widget)
        return null;
    }
}

A real minimal example from widgetsBase/widgets/image/bootstrap.php:

php
<?php
class image_PageBuilderPlugin
{
    public static function getVersion()
    {
        return "1.0.0";
    }

    public static function getName()
    {
        return "Image";
    }

    public static function getWidgetContent($widget_array, $backend = false)
    {
        return null;
    }
}

No constants, no constructor, no extends. Just three static methods.

Required methods

MethodSignaturePurpose
getVersion()public static function getVersion(): stringSemver of the widget. Currently only informational
getName()public static function getName(): stringDisplay name. Can differ from widget_title in the picker
getWidgetContent($widget_array, $backend)public static function getWidgetContent(array $widget_array, bool $backend = false)Returns dynamic data or null. Called by PageService on every render

Details on the getWidgetContent() signature and the data it receives: getWidgetContent().

Plugin ↔ widget interplay

Registration lives in the plugin, not in the widget folder:

php
// {plugin}/bootstrap.php — install() method
$this->pagebuilder_widgets = [
    '{
        "widget_title":    "My Widget",
        "widget_icon":     "icon.png",
        "widget_subtitle": "Short description for the picker",
        "widget_template": "myWidget",
        "widget_menu": [
            {"row": "title", "type": "text", "placeholder": "Title"}
        ]
    }'
];

And, separately, the widget class:

{plugin}/widgets/myWidget/bootstrap.php          # class myWidget_PageBuilderPlugin
{plugin}/widgets/myWidget/icon.png
{plugin}/widgets/myWidget/template/index.vue

On installPlugin(), the core reads the JSON from $this->pagebuilder_widgets, creates one entry per widget in page_widgets, and calls installWidgets($pluginID). After that, the widget is available in the picker.

Widget JSON — required and optional fields

json
{
  "widget_title":    "Picker label",
  "widget_icon":     "icon.png",
  "widget_subtitle": "Short description in the picker",
  "widget_template": "exactFolderName",
  "widget_menu": [ ... field types ... ]
}
FieldRequiredPurpose
widget_titleyesHeader in the picker + in the StructureTree
widget_iconyesFile name relative to the widget folder — by convention icon.png
widget_subtitlenoAdditional line in the picker
widget_templateyesMust match the widget folder name exactly
widget_menuyesArray of field types — defines the form shown in the Pagebuilder

Details on widget_menu and field types: Widget menu field types.

The Vue component template/index.vue

The component receives a single prop widget: Object. Shape:

ts
interface WidgetProp {
  data?: Record<string, any>     // Form values (title, subtitle, image, …)
  content?: any                  // Return value of getWidgetContent()
  // additional fields: id, type, visibility, spacing, ...
}

Minimal example:

vue
<template>
  <div class="my-widget">
    <h2>{{ widget.data?.title }}</h2>
    <div v-html="widget.data?.text"></div>
  </div>
</template>

<script setup lang="ts">
defineProps<{ widget: { data?: Record<string, any>; content?: any } }>()
</script>

Real-world example from shop/widgets/categoryBannerNoLink/template/index.vue (abridged):

vue
<template>
  <div class="categorySlider">
    <Swiper :modules="modules" :pagination="{ clickable: true }">
      <SwiperSlide v-for="item in widget.content?.items" :key="item.url_rewrite">
        <NuxtLink :to="`/${currentView}/${item.url_rewrite}`">
          <NuxtImg :src="item.image" :alt="item.title" format="avif" />
          <h4>{{ item.title }}</h4>
        </NuxtLink>
      </SwiperSlide>
    </Swiper>
  </div>
</template>

widget.data.* holds form values; widget.content.* comes from getWidgetContent().

How the Vue component is loaded

PagebuilderWidget.vue uses an automatically generated widget registry:

1. vueRegistry.php (cache rebuild) scans every widgets/*/template/index.vue
2. Generates plugin-registry.js per theme with a { widget_template → Vue component } mapping
3. Runtime:  <component :is="widgetRegistry[widget.type]" :widget="widget" />

That means: adding a new widget requires a cache rebuild (under /admin/cache). Changes to an existing template/index.vue don't need a rebuild because Vue/Vite live-reload, but registering a new widget folder does.

Custom theme overrides

A theme can override any widget template without forking the plugin:

_theme/{theme}/app/custom/{plugin}/widgets/{widget_template}/template/index.vue

On the next registry rebuild, this file takes precedence whenever it exists. More on this in Themes › Structure.

Common issues

Folder name ≠ widget_template

The widget lives under widgets/MyWidget/, but the JSON says "widget_template": "myWidget" — the registry scan fails to find the component. Keep both names exactly aligned (case matters).

Class name doesn't match the folder

The class in widgets/myWidget/bootstrap.php must be myWidget_PageBuilderPlugin — not MyWidget_PageBuilderPlugin, not myWidgetPlugin. On mismatch, getWidgetContent() throws Class not found.

Icon file missing

widget_icon: "icon.png" expects an icon.png in the widget root. Without the file, the picker shows a broken image. Placeholder: 64×64 or 128×128 PNG.

New widget doesn't show up in the picker

After adding a new widget, a /admin/cache rebuild is mandatory. Only then is plugin-registry.js regenerated. Updating existing widgets doesn't require a rebuild.

getWidgetContent() must return dynamic data — return type

Returning null → no content is available. Returning an array → it's available inside the Vue template under widget.content. The method must be static with the signature $widget_array, $backend = false — otherwise the core call never reaches it.

Widget in the wrong plugin directory

A widget must live in the plugin that also registers it in $this->pagebuilder_widgets. If you drop it into a foreign plugin folder, the loader cannot find the class.

See also