Skip to content

Example: Testimonial Widget

A complete Pagebuilder widget from scratch — ready to use after copy-paste and a cache rebuild. The testimonial widget renders a quote with the author's name, position, and photo. It exercises every important concept: bootstrap.php in the plugin, a widget subdirectory with its class, widget_menu fields, icon.png, and a Vue template with prop binding.

Goal

A widget with four fields:

FieldTypeUse
Nametext (title)Author of the testimonial
Positiontext (subtitle)Job title, company
Quotetextarea (text)The quote itself
Photofilemanager (image)Avatar image

Structure

You extend an existing plugin (widgetsBase in this example) with this widget:

_public/extensions/core/backend/widgetsBase/
├── bootstrap.php                 # Plugin bootstrap (add the widget registration here)
└── widgets/
    └── testimonial/              # New widget folder
        ├── bootstrap.php         # Widget class
        ├── icon.png              # Picker icon
        └── template/
            └── index.vue         # Frontend component

A dedicated plugin for widgets

In real projects, you usually put widgets in their own plugin (e.g. testimonials/), so install/uninstall moves everything together. For this example, we use widgetsBase for simplicity — in production, a separate plugin would be cleaner. See Plugins › Hello World.

1. Widget registration in widgetsBase/bootstrap.php

Inside the plugin's install(), extend the pagebuilder_widgets array. Append the new entry at the end of the list:

php
// Existing $this->pagebuilder_widgets = [...] is extended:
$this->pagebuilder_widgets[] = '{
    "widget_title":    "Testimonial",
    "widget_icon":     "icon.png",
    "widget_subtitle": "Quote with author name and photo",
    "widget_template": "testimonial",
    "widget_menu": [
        {"row": "title",    "type": "text",        "placeholder": "Author name"},
        {"row": "subtitle", "type": "text",        "placeholder": "Position / Company"},
        {"row": "text",     "type": "textarea",    "placeholder": "Quote"},
        {"row": "image",    "type": "filemanager", "placeholder": "Photo"}
    ]
}';

2. Widget class: widgets/testimonial/bootstrap.php

php
<?php

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

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

    public static function getWidgetContent($widget_array, $backend = false)
    {
        // Resolve the image URL on the server — the form field contains only
        // the media ID as URL-encoded JSON [{"id":"123"}], not the URL itself.
        $imageField = $widget_array['widget_image'] ?? '';
        $imageUrl   = !empty($imageField) ? files::loadURL($imageField) : '';

        return ['imageUrl' => $imageUrl];
    }
}

Three static methods, no inheritance. getWidgetContent() is not null because you must resolve the media ID from widget_image into a real URL on the server — files::loadURL() parses the URL-encoded JSON format [{"id":"123"}] and returns the absolute asset path. The resolved URL is then available in the Vue template under widget.content.imageUrl.

Text fields (title, subtitle, text), on the other hand, are available directly in the Vue template under widget.data.* — they need no server-side resolution.

3. Icon: widgets/testimonial/icon.png

A simple 64×64 PNG icon in the widget root — it shows up in the Pagebuilder picker next to widget_title and widget_subtitle.

Font Awesome instead of PNG

If you prefer Font Awesome icons: still drop an icon.png as a placeholder (the loader expects the file), and render the actual icon inside template/index.vue. A mechanism for SVG or FA picker icons does not currently exist in the core.

4. Vue template: widgets/testimonial/template/index.vue

vue
<template>
  <div class="testimonial-widget">
    <div class="testimonial-content">
      <blockquote v-if="widget.data?.text" v-html="widget.data.text"></blockquote>

      <div class="testimonial-author">
        <img
          v-if="widget.content?.imageUrl"
          :src="widget.content.imageUrl"
          :alt="widget.data?.title || 'Testimonial'"
          class="testimonial-photo"
        />
        <div class="testimonial-author-meta">
          <strong v-if="widget.data?.title">{{ widget.data.title }}</strong>
          <span v-if="widget.data?.subtitle">{{ widget.data.subtitle }}</span>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
interface WidgetData {
    title?: string
    subtitle?: string
    text?: string
}

interface WidgetContent {
    imageUrl?: string
}

defineProps<{
    widget: { data?: WidgetData; content?: WidgetContent }
}>()
</script>

<style scoped>
.testimonial-widget {
    padding: 2rem;
    border-radius: 0.75rem;
    background: #f8fafc;
}
.testimonial-content blockquote {
    font-size: 1.25rem;
    line-height: 1.6;
    font-style: italic;
    color: #111827;
    margin: 0 0 1.5rem 0;
}
.testimonial-author {
    display: flex;
    align-items: center;
    gap: 1rem;
}
.testimonial-photo {
    width: 64px;
    height: 64px;
    border-radius: 50%;
    object-fit: cover;
}
.testimonial-author-meta {
    display: flex;
    flex-direction: column;
}
.testimonial-author-meta strong {
    font-weight: 600;
    color: #111827;
}
.testimonial-author-meta span {
    font-size: 0.875rem;
    color: #6b7280;
}
</style>

Notes on the template

  • widget.data?.title — form value, direct. Optional chaining (?.) in case the form is still empty.
  • widget.content?.imageUrl — the media URL resolved on the server by getWidgetContent(). Text fields come from widget.data; resolved images and external data come from widget.content.
  • v-html for text — quotes and line breaks render correctly. Safe only because the content comes from the editor (CMS-trusted). See the pitfalls section below.
  • Scoped styles — scoped to this component, avoiding CSS collisions with other widgets.

5. Widget ID for custom CSS

In the Pagebuilder properties panel, editors can additionally enter custom CSS. The wrapper is .widget_{base_id}:

css
/* Custom CSS input for this widget — inside the editor */
background: #4F46E5;

blockquote {
    color: white;
    font-size: 1.5rem;
}

.testimonial-author-meta strong {
    color: white;
}

Compiles to:

css
.widget_217 { background: #4F46E5; }
.widget_217 blockquote { color: white; font-size: 1.5rem; }
.widget_217 .testimonial-author-meta strong { color: white; }

Details on the wrapper system: Custom CSS convention.

6. Reinstall the plugin and rebuild the cache

1. /admin/plugin-manager → uninstall "widgetsBase" → reinstall
   (so the new widget lands in page_widgets)

2. /admin/cache → "Clear cache"
   (so plugin-registry.js is regenerated with the new testimonial component)

7. Test it

  1. Open any page in the Pagebuilder (/admin/pagebuilder?id=…)
  2. Open the widget picker (New widget) → "Testimonial" should show up next to Title/HTML/Image, with your icon.png
  3. Drop the widget onto the page
  4. Fill the fields (Name, Position, Quote, Photo)
  5. Publish the page
  6. Open the frontend page — the testimonial renders

Extensions

What the widget could do next:

  • Multiple testimonials → use the items field type instead of single fields; see Widget items / repeater
  • Multi-language → automatic as soon as the Pagebuilder supports multilanguage (but only at the widget-item level, not per field)
  • Responsive images / srcset → additionally call files::loadURL() in multiple sizes and expose them as imageSrcset in widget.content — see getWidgetContent()
  • Scroll animation → enable a GSAP preset in the properties panel ("Animations" tab); details in the CLAUDE.md section "Pagebuilder — Scroll Animations"

Common pitfalls when reproducing

Widget doesn't show up in the picker

After adding a new widget: reinstall the plugin (or extend page_widgets manually) and rebuild the cache under /admin/cache. Without both, the widget stays invisible.

Class name ≠ widget_template

Folder widgets/testimonial/ → JSON "widget_template": "testimonial" → class testimonial_PageBuilderPlugin. All three must match exactly.

Asymmetric keys for "row": "text"

For "row": "text" in the widget_menu:

  • PHP (getWidgetContent): access via $widget_array['widget_html']PageService.php aliases the text column to widget_html.
  • Vue (template/index.vue): access via widget.data.text — the row column is used unchanged.

This is asymmetric: the Vue key maps to the DB column directly, while the PHP key has a different name. If you read $widget_array['widget_text'] in PHP or widget.data.html in Vue, you get null.

v-html with untrusted content

v-html renders HTML unescaped — fine for Pagebuilder widgets because the content comes from the editor (CMS-trusted). Never with user input from the frontend (form submits, comments) — XSS risk.

Icon file missing

widget_icon: "icon.png" expects the file in the widget root. Without icon.png, the picker shows a broken image.

Custom CSS with .widget_217 { ... } (selector including the wrapper)

The editor expects the wrapper's contents only. See Custom CSS convention › Common issues.

See also