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 componentFolder 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
// _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
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
| Method | Signature | Purpose |
|---|---|---|
getVersion() | public static function getVersion(): string | Semver of the widget. Currently only informational |
getName() | public static function getName(): string | Display 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:
// {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.vueOn 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
{
"widget_title": "Picker label",
"widget_icon": "icon.png",
"widget_subtitle": "Short description in the picker",
"widget_template": "exactFolderName",
"widget_menu": [ ... field types ... ]
}| Field | Required | Purpose |
|---|---|---|
widget_title | yes | Header in the picker + in the StructureTree |
widget_icon | yes | File name relative to the widget folder — by convention icon.png |
widget_subtitle | no | Additional line in the picker |
widget_template | yes | Must match the widget folder name exactly |
widget_menu | yes | Array 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:
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:
<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):
<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.vueOn 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
- Widgets overview — concept and lifecycle
- Widget menu field types — text/textarea/select/filemanager/items
- getWidgetContent() — dynamic data in PHP
- Custom CSS convention —
.widget_{id}wrappers - Example: Testimonial Widget — complete widget from scratch
- Plugin Anatomy —
$this->pagebuilder_widgetsinside the plugin'sinstall()