Widgets and Widget Areas

Widgets are small, configurable blocks of output that administrators drop into areas (blog sidebar, footer columns…) without touching code. They are built on arrilot/laravel-widgets. The same widget class technology also powers dashboard cards.

In the user guide: Widgets.

Vocabulary

TermWhat it isStored in
Widget classPHP class extending App\Widgets\AbstractWidgetCode; registered with Widgets::register($key, $class)
Widget areaA named place in a theme (post-sidebar, footer-1…)widget_areas table (name, title, description, order, soft-deletable)
Widget instanceOne placement of a class in an area, with its own title and settingswidgets table (name = class, title, settings, order, web, mobile, ajax, status)

Built-in classes (WidgetsServiceProvider): tru-menu, tru-text, tru-html, tru-advertisement, tru-posts, tru-posts-categories, tru-posts-tags. Seeded areas: sidebar (homepage), categories-sidebar, tags-sidebar, pages-sidebar, post-sidebar, and footer-1…footer-6 (extra footer columns are soft-deleted and restored by the theme option footer_widget_columns).

Build a widget

1. The class

Modules/Playground/app/Widgets/NoteWidget.php

use App\Widgets\AbstractWidget;

class NoteWidget extends AbstractWidget
{
    protected array $config = ['title' => false, 'limit' => 2];       // defaults; must stay typed as array

    public const TITLE = 'playground::p.widget_title';               // shown in the admin widget list
    public const DESCRIPTION = 'playground::p.widget_description';
    public const VIEW = 'playground::widgets.notes';                 // public markup
    public const ADMIN_VIEW = 'playground::widgets.notes-admin';     // settings form shown in Appearance -> Widgets

    // The widget manager passes ['title' => ..., 'settings' => object|array]; flatten it into $config.
    public function __construct(array $config = [])
    {
        $settings = isset($config['settings']) ? (array) $config['settings'] : [];
        $settings['title'] = $config['title'] ?? '';

        parent::__construct($settings);
    }

    public function run(): View
    {
        return view(static::VIEW, [
            'title' => $this->config['title'] ?: false,
            'notes' => Note::query()->latest('id')->limit(max(1, (int) ($this->config['limit'] ?? 2)))->get(),
        ]);
    }
}

AbstractWidget adds to Arrilot's base: translatable get_title()/get_description() from the constants, a build($sidebar, $widget) method that renders ADMIN_VIEW for the admin screen, viewable()/admin_view() existence checks, and a $fields map that decides which standard controls the editor shows: title, web, mobile, ajax, status (all true; turn one off with $this->set_fields('ajax', false) in the constructor, as the HTML widget does).

2. The admin editor view

resources/views/widgets/notes-admin.blade.php

@extends('widgets.template')

@section('widget-form-' . $widget->id)
    <div class="mb-3">
        <label class="form-label" for="settings-limit-{{ $widget->id }}">{{ __('playground::p.limit') }}</label>
        <input type="number" min="1" max="12" class="form-control" id="settings-limit-{{ $widget->id }}"
            name="settings[limit]" value="{{ $widget->settings->limit ?? 2 }}">
    </div>
@endsection

The template (resources/themes/admin/widgets/template.blade.php) supplies the card, the title input and the web/mobile/async/status switches, and posts the form to admin.widgets.update. Everything you name settings[...] is stored as the instance's settings and returned to your constructor as $config['settings'].

3. The public view

resources/views/widgets/notes.blade.php

<section class="widget mb-4">
    @if ($title)<h3 class="h5">{{ $title }}</h3>@endif
    @foreach ($notes as $note)<p>{{ $note->title }}</p>@endforeach
</section>

Use Bootstrap utility classes, escape everything, and return nothing (an empty view) when there is nothing to show so empty areas collapse (the Echo wrappers check Widget::group('name')->isEmpty()).

4. Register it

Provider boot()

use App\Facades\Widgets;

Widgets::register('playground-notes', NoteWidget::class);   // key must be unique; prefix it with your alias

That is all: the class now appears in Appearance → Widgets for every area. (Admins need the view widgets permission for the screen; see Known quirks.) The admin page lists Widgets::all() and validates additions against it.

How instances get on the page

  1. An admin adds a widget in Appearance → Widgets (admin.widgets.store creates a row and returns the editor HTML) and edits it (update saves title, settings and flags). Verified end to end.
  2. WidgetsServiceProvider waits until every provider has booted (so module widget classes are registered), then loads the active areas and instances for the visitor's device class (web or mobile, via MobileDetect), cached for one hour per device (widget_areas_web, widget_areas_mobile), and calls Widget::group($area)->position($order)->addWidget($class, ['title' => ..., 'settings' => ...]) (or addAsyncWidget when the instance is marked async).
  3. Instances whose class is no longer registered (plugin deactivated) are skipped silently.
  4. Themes print an area with @widgetGroup('post-sidebar') (or {!! Widget::group('post-sidebar')->display() !!}) and guard with @if (! Widget::group('post-sidebar')->isEmpty()).
  5. Cache caveat: nothing in the code base forgets widget_areas_web / widget_areas_mobile when instances change (the model's default cache keys are different, and the response cache lives in its own store). A placement edited in the admin can therefore take up to an hour to appear on the public site unless the application cache is cleared. In the test suite the response cache shares the default array store, which hides this. After changing instances in code or tests call Cache::forget('widget_areas_web') and Cache::forget('widget_areas_mobile').

Pages can embed an area too

The core shortcode widget-area (<x-shortcode-widget-area widget="post-sidebar">) renders any area inside page content, so editors can reuse your widgets in the middle of a page.

Arrilot behaviour you can use

Property / methodEffect
public $cacheTime = 30; (minutes) and cacheKey(array $params)Cache the rendered widget. The default key serialises the params; the base class prefixes artisan.widgets.
Async instance (ajax = 1) + placeholder()Loaded after page load from arrilot/load-widget; the placeholder shows meanwhile
public $reloadTimeout = 60; (seconds)Async widgets re-fetch themselves periodically
container(): arrayWrapper element for async/reloadable widgets (default div.widget-container)
run() dependenciesType-hinted arguments are resolved from the container (PostsWidget::run(PostRepository $repository))

Do not use async widgets for anything above the fold or in the response cache's guest-shared pages unless the output is not per-visitor.

Create a new widget area

An area is just a row. Seed it in a migration/seeder and print it from your theme or page view:

php (migration or seeder)

WidgetArea::firstOrCreate(
    ['name' => 'shop-sidebar'],
    ['title' => 'Shop', 'description' => 'Widgets next to the shop archive.', 'order' => 20],
);
<x-front-layout>
    ...
    @if (! Widget::group('shop-sidebar')->isEmpty())
        <x-slot:sidebar><div class="sidebar sidebar-lg">@widgetGroup('shop-sidebar')</div></x-slot>
    @endif
</x-front-layout>

TruCommerce does this for its shop sidebar; the page builder's Sidebar block lists all areas via PageBuilderPresenter::widgetAreasJson().

Widgets vs. shortcodes vs. data-bound sections

Use a…when…
WidgetThe same content should repeat on many pages in a fixed region and an admin should manage it without the page builder
ShortcodeIt belongs inside one page's content and the editor controls where it sits
Data-bound sectionThe editor designs the markup and wants real records inside it

It is common to ship all three from one component: the Testimonials example has a shortcode and a widget that read the same model.