If you are not interested in using Devflow as a headless CMS, you have the option of creating a theme. When creating a theme, you can extend the abstract class Theme and implement the two abstract methods meta and handle. The meta method returns an array of info about your theme, and the handle method is called by the system to load assets, views, etc. The following 4 steps will walk you through creating your first theme.

Best Practices in Theme Development

Using Coding Standards

Devflow uses specific coding standards to help maintain consistency, quality, and clean code. The coding standard that Devflow uses and promotes is PSR-12 along with the Qubus Coding Standards.

Namespaces

Classes as well as function should be namespaced, unless there is a strong reason to not namespace a function. Namespacing functions is recommended and highly encouraged in order to keep the global namespace clean and available for native PHP functions.

Security

You must always validate and sanitize data on output. Never trust user data that was inputted. If you use the native Devflow functions that output data, that data is already sanitized or purified. Keep these things in mind when outputting data outside the native functions:

  • Sanitize data on output by using helpers Qubus\Security\Helpers\esc_html, Qubus\Security\Helpers\esc_html__, Qubus\Security\Helpers\esc_url, Qubus\Security\Helpers\esc_js, and Qubus\Security\Helpers\purify_html.
  • Check user permission before executing or saving data to prevent unauthorized operations or access.
  • Whenever possible, use (Qubus\Expressive\Database) as a dependency and prepared statements to avoid SQL injection vulnerabilities.

Step #1: Create A Native Theme

The first step is to create a folder for your theme in the public/themes directory. Your directory must meet PSR-4 autoload standards. For this example, we are going to create a My Site theme. The name of the new folder will be MySite and the name of the main class will be the same followed by the suffix Theme.php: MySiteTheme.php.

MySiteTheme.php will extend the abstract class App\Infrastructure\Services\Theme. We should now have a new class:

<?php

declare(strict_types=1);

namespace Theme\MySite;

use App\Infrastructure\Services\Theme;

class MySiteTheme extends Theme
{
    /**
     * @inheritDoc
     */
    public function meta(): array
    {
        // TODO: Implement meta() method.
    }

    /**
     * @inheritDoc
     */
    public function handle(): void
    {
        // TODO: Implement handle() method.
    }
}

We need to fill out the meta method to include our theme's info:

  • name - The name of the theme.
  • slug - The proper name of your theme with no spaces (PascalCase).
  • id - Theme's unique identifier. This is also used for your route if you need to register a submenu.
  • author - Person or company who authored the theme.
  • version - Current version of the theme.
  • description - A short description of the theme's purpose.
  • basename - Filename of the theme.
  • path - File path of the theme.
  • url - Theme's directory url.
  • themeUri - Where the theme is hosted and updates found.
  • authorUri - Website of the theme author.
  • className - Name of the class.
  • screenshot - Preview image of the theme.

With the meta details filled out, this is now the state of our theme class:

<?php

declare(strict_types=1);

namespace Theme\MySite;

use App\Infrastructure\Services\Theme;
use App\Shared\Services\Registry;

use function App\Shared\Helpers\theme_root;
use function App\Shared\Helpers\theme_url;
use function basename;
use function dirname;
use function get_class;
use function Qubus\Security\Helpers\t__;

class MySiteTheme extends Theme
{
    /**
     * @inheritDoc
     */
    public function meta(): array
    {
        $theme = [
            'name' => t__(msgid: 'My Site Theme', domain: 'mysite'),
            'id' => 'mysite',
            'slug' => 'MySite',
            'author' => 'Joshua Parker',
            'version' => '1.0.0',
            'description' => 'My Site theme.',
            'basename' => basename(dirname(__FILE__)),
            'path' => theme_root(__FILE__),
            'url' => theme_url('', __CLASS__),
            'themeUri' => 'https://github.com/getdevflow/mysite-theme',
            'authorUri' => 'https://nomadicjosh.com/',
            'className' => get_class($this),
            'screenshot' => theme_url('MySite/screenshot.png'),
        ];

        Registry::getInstance()->set('mysite', $theme;

        return $theme;
    }

    /**
     * @inheritDoc
     */
    public function handle(): void
    {
        // TODO: Implement handle() method.
    }
}

Now that the meta method is implemented, our theme should now be registered on our themes page.

Step #2: Create Other Methods (if needed)

You can create other methods as needed such as rendor for views. To get an idea, check out the Plugin guide

Step #3: The Loop

For your main view, you can use the loop to load all content or content by content type:

    if(has_content()):
        while(the_content()) :
            //
            // Content here
            //
        endwhile;
    endif;
Check out theme functions that can be used in the loop: content_* or product_*.

Step #4: Share Your Theme

If you wanted to share your theme to allow others in the community to install it via composer, you need to add a composer.json file to the root of your theme's directory:

{
  "name": "getdevflow/mysite",
  "description": "My Site theme.",
  "type": "devflow-thee",
  "keywords": ["devflow-theme","themes"],
  "license": "GPL-2.0-only",
  "authors": [
    {
      "name": "Joshua Parker",
      "email": "joshua@joshuaparker.dev"
    }
  ],
  "require": {
    "php": ">=8.4",
    "oomphinc/composer-installers-extender": "^2.0"
  },
  "extra": {
    "installer-name": "MySite",
    "installer-types": ["devflow-theme"]
  },
  "minimum-stability": "stable",
  "prefer-stable": true,
  "config": {
    "allow-plugins": {
      "composer/installers": true,
      "oomphinc/composer-installers-extender": true
    }
  }
}

There are several important key points to point out in the JSON data above:

  1. Make sure to change the second part of vendor name to match your theme
  2. Make sure to add the type devflow-theme
  3. Make sure to include installer-name and make it PSR-4 compatible. It will install the theme as MySite instead of as mysite.
  4. Make sure the installer-types includes devflow-theme

When someone runs composer require getdevflow/mysite, the theme will be installed with the correct folder name to meet PSR-4 standards: public/themes/MySite/.

There you go. These are the steps you can take to create a theme as well as how to share your new theme with the Devflow community. Any theme added to Packagist with the above composer.json information, will appear on the Devflow extensions page of the main website.

Child Themes

If you need or want to customize a theme, you can create a child theme. If we were to create a child theme of the sample theme made previously, we would create another directory named MySiteChild, and the main class will be named MySiteChildTheme, which will extend the class of the main theme:

    <?php

    declare(strict_types=1);

    namespace Theme\MySiteChild;

    use Theme\MySite\MySiteTheme;

    final class MySiteChildTheme extends MySiteTheme {

    }
As an example, you can download the VaporChild Theme which is a child theme of Vapor.

Create a Page Builder Theme

Devflow integrates the Vihzhuo page builder. A page-builder theme is still a normal Devflow theme: it has a theme class, metadata, a handle() method, and can be activated from the Themes screen. It additionally supplies Vihzhuo layouts and blocks and explicitly enables page-builder support.

Devflow owns the Vihzhuo application integration, including authentication, manager routes, persistence, uploads, and public-page routing. Theme developers do not need to instantiate Vihzhuo or install it separately. The theme's responsibility is to provide the presentation layer described in this section.

Note

Page-builder themes cannot be used as parent themes. To customize a third-party page-builder theme, copy it to a new folder, rename its namespace, class, slug, and metadata, and activate the copy. Otherwise, a Composer update can overwrite local changes.

How Devflow and Vihzhuo fit together

When an administrator opens the Website Manager, Devflow:

  1. verifies that the user has the vihzhuo:manage permission;
  2. checks the pagebuilder.support filter exposed by the active theme;
  3. loads the Vihzhuo configuration from config/vihzhuo.php;
  4. discovers layouts and blocks in the active Devflow theme; and
  5. renders the selected layout and editable page body in GrapesJS.

On a public request, Devflow asks Vihzhuo to resolve the page route and render its saved content through the same active theme. This means a layout and every dynamic block must work both inside the editor canvas and on the public website.

Step 1: Enable the page builder

First create the native Devflow theme described earlier in this guide. The theme folder, namespace segment, and slug metadata should match. For an Acme theme example, the important part of the class is the pagebuilder.support filter:

<?php

declare(strict_types=1);

namespace Theme\Acme;

use App\Infrastructure\Services\Theme;
use App\Shared\Services\Registry;
use Qubus\EventDispatcher\ActionFilter\Filter;

use function App\Shared\Helpers\theme_root;
use function App\Shared\Helpers\theme_url;
use function basename;
use function dirname;
use function get_class;
use function Qubus\Security\Helpers\t__;

final class AcmeTheme extends Theme
{
    public function meta(): array
    {
        $theme = [
            'name' => t__(msgid: 'Acme Theme', domain: 'acme'),
            'id' => 'acme',
            'slug' => 'Acme',
            'author' => 'Acme, Inc.',
            'version' => '1.0.0',
            'description' => 'A page-builder theme for the Acme website.',
            'basename' => basename(dirname(__FILE__)),
            'path' => theme_root(__FILE__),
            'url' => theme_url('', __CLASS__),
            'themeUri' => 'https://example.com/acme-theme',
            'authorUri' => 'https://example.com',
            'className' => get_class($this),
            'screenshot' => theme_url('Acme/images/screenshot.png'),
        ];

        Registry::getInstance()->set('acme', $theme);

        return $theme;
    }

    public function handle(): void
    {
        Filter::getInstance()->addFilter(
            'pagebuilder.support',
            static fn (): bool => true
        );

        // Register any other Devflow actions, filters, or theme services here.
    }
}

The Website Manager is unavailable when this filter is missing or returns false. A normal, non-page-builder theme should leave the filter unset or return false.

Next, enable Vihzhuo in config/vihzhuo.php. Keep Devflow's existing database, authentication, page, and route classes; only verify the following values:

<?php

declare(strict_types=1);

use App\Infrastructure\Services\Vihzhuo\VihzhuoTheme;

use function Codefy\Framework\Helpers\public_path;

return [
    'enable' => true,

    // Keep the other Devflow Vihzhuo settings here.

    'theme' => [
        'class' => VihzhuoTheme::class,
        'folder' => public_path('themes'),
        'folder_url' => '/themes',
        'active_theme' => VihzhuoTheme::activeTheme(themeName: 'Acme'),
    ],

    'router' => [
        'class' => Vihzhuo\Modules\Router\DatabasePageRouter::class,
        'use_router' => true,
    ],
];

VihzhuoTheme::activeTheme() reads the activated Devflow theme and converts identifiers such as Theme\Acme\AcmeTheme to the Acme folder name. Its argument is the fallback used when no theme has been stored yet. A fixed value such as 'active_theme' => 'Acme' also works for a single-theme installation, but it can get out of sync with the theme selected in Devflow.

The remaining relevant Devflow defaults are:

Configuration key Devflow default responsibility
general.assets_url Public route for Vihzhuo's editor assets, normally /phpb-assets.
general.uploads_url Public route for images uploaded through the Asset Manager.
storage.uploads_folder Writable private storage used for Asset Manager files.
website_manager.url Devflow's Website Manager route, normally /admin/manager/.
pagebuilder.url GrapesJS editor endpoint, normally /admin/manager/pagebuilder/.
cache.enabled and cache.folder Rendered-page cache configuration.

Do not copy the standalone Vihzhuo example configuration over Devflow's configuration. In particular, Devflow supplies its own authenticated VihzhuoAuth implementation and database prefix.

Step 2: Create the theme structure

Vihzhuo uses directory conventions. A complete Devflow page-builder theme can look like this:

public/themes/Acme/
├── AcmeTheme.php
├── blocks/   ├── hero/      ├── config.php      └── view.html   ├── callout/      ├── config.php      ├── view.php      ├── script.js             # optional public behavior      └── builder-script.js     # optional editor-only behavior   ├── latest-posts/      ├── config.php      ├── model.php             # optional server-side data      ├── controller.php        # optional request coordination      └── view.php   ├── elements/                 # optional organizational folder   ├── php/                      # optional organizational folder   └── archived/                 # optional organizational folder
├── layouts/   └── main/       ├── config.php       └── view.php
├── translations/   ├── en.php                    # optional Vihzhuo label overrides   └── es.php
├── public/   ├── css/theme.css   ├── js/theme.js   ├── images/logo.svg   └── block-thumbs/             # generated thumbnails, when used
├── composer.json                 # required only for a distributable theme
└── README.md

Blocks are discovered one directory below blocks/, blocks/elements/, blocks/php/, and blocks/archived/. Layouts are discovered one directory below layouts/. Deeper folders are not recursively discovered.

Devflow exposes files in the theme's public/ directory at /themes/Acme/. Consequently, pass paths relative to public/ to phpb_theme_asset():

<img src="<?= phpb_theme_asset('images/logo.svg') ?>" alt="Acme">

Static HTML block files cannot call PHP helpers. Use the equivalent shortcode there:

<img src="[theme-url]/images/logo.svg" alt="Acme">

Step 3: Create a layout

A page must be assigned a layout. Each layout is a folder containing view.php and an optional config.php.

public/themes/Acme/layouts/main/config.php:

<?php

return [
    'title' => 'Main layout',
];

The title is shown when an editor creates or updates a page. If it is omitted, Vihzhuo derives a title from the layout slug.

public/themes/Acme/layouts/main/view.php:

<?php

use function App\Shared\Helpers\cms_body_open;
use function App\Shared\Helpers\cms_footer;
use function App\Shared\Helpers\cms_head;

?>
<!doctype html>
<html lang="<?= phpb_e(phpb_current_language()) ?>">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title><?= phpb_e((string) $page->getTranslation('meta_title')) ?></title>
    <meta
        name="description"
        content="<?= phpb_e((string) $page->getTranslation('meta_description')) ?>"
    >

    <link rel="stylesheet" href="<?= phpb_theme_asset('css/theme.css') ?>">
    <?php phpb_registered_assets('header'); ?>
    <?php cms_head(); ?>
</head>
<body>
    <?php cms_body_open(); ?>

    <header class="site-header">
        <a href="<?= phpb_full_url('/') ?>">
            <img src="<?= phpb_theme_asset('images/logo.svg') ?>" alt="Acme">
        </a>
    </header>

    <main id="content">
        <?= $body ?>
    </main>

    <script src="<?= phpb_theme_asset('js/theme.js') ?>" defer></script>
    <?php phpb_registered_assets('footer'); ?>

    <script>
    document.addEventListener('DOMContentLoaded', () => {
        document
            .querySelectorAll('script[type="text/javascript"][class^="script"]')
            .forEach(script => script.dispatchEvent(new Event('run-script')));
    });
    </script>

    <?php cms_footer(); ?>
</body>
</html>

The layout receives these variables:

Variable Type Purpose
$page Vihzhuo\Contracts\PageContract Current page record, layout selection, route, and translated metadata.
$renderer Vihzhuo\Modules\GrapesJS\PageRenderer Advanced body, block, and shortcode rendering.
$body string The editable content container in GrapesJS or the rendered page content publicly.

Output $body without escaping. Vihzhuo owns that markup, and escaping it would display the page as text. Escape metadata and all other untrusted values. Keep cms_head(), cms_body_open(), and cms_footer() so Devflow and its extensions can participate in the page lifecycle. Keep phpb_registered_assets() if application code or a Composer package can register Vihzhuo assets.

Layout stylesheets are loaded into the GrapesJS canvas, which makes the editor preview match the public page. Shared libraries belong in the layout; per-instance behavior belongs with the relevant block.

Layouts may also contain fixed blocks. For example, this renders a header that editors cannot accidentally remove:

<body>
    [block slug="site-header" id="layout-header"]
    <?= $body ?>
    [block slug="site-footer" id="layout-footer"]
</body>

Step 4: Create blocks

The view filename determines the block type:

View file Use it for What Vihzhuo stores
view.html Editable text, images, links, and ordinary markup The editor-modified HTML. PHP settings are not available.
view.php Dynamic data, controlled variants, or server-side behavior The block settings and nested block data; PHP rerenders the markup.

Use an HTML block when an editor should freely change the block's content and structure. Use a PHP block when the theme must retain control of its markup or load application data.

Static HTML block

public/themes/Acme/blocks/hero/config.php:

<?php

return [
    'category' => 'Marketing',
    'title' => 'Hero',
    'icon' => 'fa fa-star',
];

public/themes/Acme/blocks/hero/view.html:

<section class="hero">
    <p class="hero__eyebrow">New release</p>
    <h1>Build the next great thing</h1>
    <p>Double-click this text to edit it.</p>
    <a class="button" href="/contact">Talk to us</a>
</section>

The folder name, hero, is the block slug. Vihzhuo discovers the block automatically and adds it to the Marketing category in the block picker.

Dynamic PHP block with settings

public/themes/Acme/blocks/callout/config.php:

<?php

return [
    'category' => 'Marketing',
    'title' => 'Callout',
    'icon' => 'fa fa-bullhorn',
    'wrapper' => 'section',
    'cache' => true,
    'cache_lifetime' => 60,
    'settings' => [
        'heading' => [
            'type' => 'text',
            'label' => 'Heading',
            'value' => 'Ready to get started?',
            'placeholder' => 'Callout heading',
        ],
        'tone' => [
            'type' => 'select',
            'label' => 'Tone',
            'options' => [
                ['value' => 'callout--info', 'label' => 'Information'],
                ['value' => 'callout--success', 'label' => 'Success'],
                ['value' => 'callout--warning', 'label' => 'Warning'],
            ],
            'value' => 'callout--info',
        ],
        'show_link' => [
            'type' => 'yes_no',
            'label' => 'Show link',
            'value' => '1',
        ],
        'link_url' => [
            'type' => 'text',
            'label' => 'Link URL',
            'value' => '/contact',
        ],
    ],
];

public/themes/Acme/blocks/callout/view.php:

<?php

$showLink = $block->setting('show_link') === '1';

?>
<aside class="callout <?= $block->setting('tone') ?>">
    <h2><?= $block->setting('heading') ?></h2>

    <?php if ($showLink): ?>
        <a href="<?= $block->setting('link_url') ?>">Contact us</a>
    <?php endif; ?>
</aside>

$block->setting() escapes its result by default, so it is appropriate for normal text and attributes. Passing true as its second argument returns unescaped HTML and should only be done for content that the application has already sanitized:

<?= $block->setting('trusted_html', true) ?>

A dynamic block view receives:

Variable Type Purpose
$block BaseModel or the block's custom model Reads settings and exposes application data.
$page PageContract The current page.
$renderer BlockRenderer Renders another block programmatically.
$hasSkeleton bool Whether the model enabled skeleton rendering.
$hasDynamicSkeleton bool Whether the skeleton includes dynamic preview content.

When an editor changes a PHP block setting, Vihzhuo sends the block data to Devflow's page-builder endpoint, rerenders the PHP view, replaces the component in the canvas, and reselects it.

Block configuration reference

Key Meaning
title Label in the block picker; defaults to a title derived from the slug.
category Block-picker category; defaults to Vihzhuo's translated default category.
icon Font Awesome class used when no generated thumbnail exists.
hidden If true, the block can be rendered by another block or a layout but is omitted from the picker.
settings Settings displayed for a dynamic view.php block.
wrapper Trusted HTML element used to wrap a styled dynamic block; defaults to div.
cache Set to false when a page containing the block must not be cached.
cache_lifetime Positive number of minutes that can shorten the containing page's cache lifetime.
whitelist List of trusted domain or URL fragments on which the block is registered.
namespace Namespace containing optional Model and Controller classes.

If several blocks specify a cache lifetime, the shortest lifetime wins for the complete rendered page. For example, a page containing 60-minute and 15-minute blocks expires after 15 minutes. Use 'cache' => false for personalized or request-specific output. Do not use a zero lifetime as a substitute.

Settings support these commonly used field types:

Type Editor control Notes
text Single-line input Default type.
number Numeric input The model receives the stored value as a string.
select Select menu Requires an options array.
checkbox Checkbox Use an empty default for unchecked.
color Color picker Produces a CSS color such as #2563eb.
yes_no Yes/No select Normalized to 0 and 1.

Every setting needs a label or it is omitted from the Settings tab. value supplies the default and placeholder is used by controls that support one. Select options may use either form:

'options' => [
    ['value' => 'sm', 'label' => 'Small'],
    ['value' => 'lg', 'label' => 'Large'],
],

// GrapesJS-compatible alternative:
'options' => [
    ['id' => 'sm', 'name' => 'Small'],
    ['id' => 'lg', 'name' => 'Large'],
],

Step 5: Compose blocks and editable regions

Dynamic blocks and layouts can render other blocks with shortcodes. Use a stable, unique id whenever multiple instances of the same child block may appear:

<section class="feature-grid">
    <?php for ($index = 0; $index < 3; $index++): ?>
        [block slug="feature-card" id="feature-card-<?= $index ?>"]
    <?php endfor; ?>
</section>

Additional shortcode attributes become settings for that render:

[block slug="badge" id="new-badge" tone="success" label="New"]

Set 'hidden' => true in the child block's config.php when it is an implementation detail that should not appear by itself in the block picker.

Use [blocks-container] to create a drop zone inside a dynamic block:

<section class="columns">
    <div class="column">[blocks-container]</div>
    <div class="column">[blocks-container]</div>
</section>

Vihzhuo converts each shortcode to <div phpb-blocks-container></div> and configures it as a GrapesJS child container. Available rendering shortcodes are:

Shortcode Result
[block slug="hero" id="home-hero"] Renders a theme or registered extension block.
[blocks-container] Adds an editor drop zone.
[theme-url] Resolves the active theme's public URL.
[page id="12"] Resolves the public route for page ID 12.

Block shortcode nesting is limited to 25 levels. Reaching the limit usually means two blocks reference each other.

Step 6: Add a custom model or controller

Dynamic blocks use Vihzhuo's BaseModel and BaseController unless the block contains model.php or controller.php. Set an explicit namespace so the class does not depend on namespace inference.

public/themes/Acme/blocks/latest-posts/config.php:

<?php

return [
    'category' => 'Content',
    'title' => 'Latest posts',
    'namespace' => 'Theme\\Acme\\Blocks\\LatestPosts',
    'cache_lifetime' => 15,
    'settings' => [
        'limit' => [
            'type' => 'number',
            'label' => 'Number of posts',
            'value' => '3',
        ],
    ],
];

public/themes/Acme/blocks/latest-posts/model.php:

<?php

declare(strict_types=1);

namespace Theme\Acme\Blocks\LatestPosts;

use Vihzhuo\Modules\GrapesJS\Block\BaseModel;

final class Model extends BaseModel
{
    /** @var list<array{title: string, url: string}> */
    private array $posts = [];

    protected function init(): void
    {
        $limit = max(1, (int) $this->setting('limit'));

        // Replace this example with a Devflow query or an injected application service.
        $this->posts = array_slice([
            ['title' => 'First post', 'url' => '/blog/first-post'],
            ['title' => 'Second post', 'url' => '/blog/second-post'],
        ], 0, $limit);
    }

    protected function initEdit(): void
    {
        // Keep editor previews fast and deterministic when production data is expensive.
        $this->posts = [
            ['title' => 'Example post', 'url' => '#'],
        ];
    }

    /** @return list<array{title: string, url: string}> */
    public function posts(): array
    {
        return $this->posts;
    }
}

public/themes/Acme/blocks/latest-posts/view.php:

<section class="latest-posts">
    <h2>Latest posts</h2>
    <ul>
        <?php foreach ($block->posts() as $post): ?>
            <li>
                <a href="<?= phpb_e($post['url']) ?>">
                    <?= phpb_e($post['title']) ?>
                </a>
            </li>
        <?php endforeach; ?>
    </ul>
</section>

Use a controller only when the block must coordinate request-level behavior. Its Controller class must extend BaseController:

<?php

declare(strict_types=1);

namespace Theme\Acme\Blocks\LatestPosts;

use Vihzhuo\Modules\GrapesJS\Block\BaseController;

final class Controller extends BaseController
{
    public function handleRequest(): void
    {
        // $this->model, $this->page, and $this->forPageBuilder are available here.
    }
}

Models and controllers are trusted server-side code. Validate data loaded from Devflow or external services before exposing it to the view, then escape it at the output context.

Step 7: Add block JavaScript

A block may contain one public script named script.js, script.html, or script.php. It may also contain an editor-specific version named builder-script.js, builder-script.html, or builder-script.php. When no builder script exists, Vihzhuo uses the public script in the editor.

Scripts run with three instance-specific variables:

// Root element of this block instance.
block.classList.add('is-initialized');

// A unique selector for this instance.
document.querySelector(blockSelector)?.setAttribute('data-ready', 'true');

// true in GrapesJS and false on the public page.
if (inPageBuilder) {
  block.classList.add('is-preview');
}

The editor runs builder scripts when components mount or update. Public scripts wait for a run-script event, which is why the layout example includes the block-script bootstrap. Put shared dependencies in the layout and keep block scripts limited to behavior for one rendered instance.

Step 8: Register shared blocks, layouts, and assets

A Devflow theme can register resources stored outside its convention folders. This is useful when a plugin or Composer package owns a reusable design system. Register them before Vihzhuo enumerates the active theme, normally from the theme's handle() method or the providing plugin's bootstrap:

<?php

use Vihzhuo\Extensions;

Extensions::registerBlock(
    'pricing-table',
    __DIR__ . '/PageBuilder/Blocks/PricingTable'
);

Extensions::registerLayout(
    'campaign',
    __DIR__ . '/PageBuilder/Layouts/Campaign'
);

Extensions::registerAsset(
    '/build/page-blocks.css',
    'style',
    'header',
    ['media' => 'screen']
);

Extensions::registerAsset(
    '/build/page-blocks.js',
    'script',
    'footer',
    ['defer' => 'defer']
);

Registered block and layout folders follow the same file conventions as theme-owned folders. Registered assets are printed by phpb_registered_assets('header') and phpb_registered_assets('footer') in the layout. URLs, element names, and attributes registered this way are trusted developer configuration and must not be assembled from user input.

Application code can also replace a dynamic block's entire settings definition before the editor is rendered:

use Vihzhuo\ThemeBlock;

ThemeBlock::set('callout', 'settings', [
    'heading' => [
        'type' => 'text',
        'label' => 'Heading',
        'value' => 'Site-specific default',
    ],
]);

The supplied array replaces the file-based settings array; it does not merge individual fields.

Translations, routes, and navigation

Optional files in translations/ return arrays that are merged over Vihzhuo's built-in language file. The current locale file is merged over translations/en.php:

<?php

return [
    'pagebuilder.default-category' => 'Acme blocks',
];

Devflow's Website Manager stores each page's translated title, metadata, and route. Vihzhuo supports exact routes, named parameters such as /blog/{slug}, and wildcards such as /documentation/*. Read a named parameter in a PHP block with:

$slug = phpb_route_parameter('slug');
$allParameters = phpb_route_parameters();

Useful layout and block helpers include:

phpb_current_language();
phpb_active_languages();
phpb_current_relative_url();
phpb_full_url('/contact');
phpb_url('pagebuilder', ['page' => $page->getId()]);
phpb_pages();

Use Devflow's nav_links() helper when building navigation from pages that administrators marked for navigation. As a better alternative, you can use the Menu Builder plugin.

Caching and request-specific blocks

Rendered-page caching is controlled globally in config/vihzhuo.php:

'cache' => [
    'enabled' => true,
    'folder' => storage_path('framework/cache'),
    'class' => Vihzhuo\Cache::class,
],

Saving a page invalidates its cached route variants. During development, add ?ignore_cache to bypass cache reads and writes or ?refresh_cache to regenerate the current response. Mark blocks containing the current user, a CSRF token, cart state, or other request-specific data with 'cache' => false.

Security checklist

  • Only users with the vihzhuo:manage permission should access the Website Manager and editor.
  • Use phpb_e() for untrusted HTML text and attributes and phpb_json() for data embedded in a script.
  • Remember that $block->setting() escapes by default; allow raw HTML only after application-level sanitization.
  • Output $body unescaped because it is the renderer-owned page markup.
  • Treat layouts, block configuration, PHP views, models, controllers, scripts, and registered assets as trusted code.
  • Never build a tag name, registered asset URL, namespace, or filesystem path from editor input.
  • Validate data returned by Devflow queries and external APIs before rendering it.
  • Keep uploads and cache folders writable by PHP but outside theme source control.

Test the theme

After activating the theme and enabling Vihzhuo:

  1. sign in as a user with vihzhuo:manage and open Website Manager;
  2. create a page, select Main layout, and provide a public route;
  3. open the page builder and confirm every visible block appears in the correct category;
  4. drag each HTML and PHP block into the canvas and exercise every setting;
  5. test nested drop zones, images, responsive previews, and editor-specific JavaScript;
  6. save, reload the editor, and verify that content and settings persist;
  7. visit the public route while signed out and compare it with the editor preview; and
  8. repeat for each active language and with caching both disabled and enabled.

If the Website Manager reports access denied, verify all three gates: vihzhuo.enable is true, the active theme's pagebuilder.support filter returns true, and the current role has vihzhuo:manage. If layouts or blocks are missing, check folder depth, filenames, the active theme slug, and PHP errors in each config.php.

Dynamic Themes

Devflow CMS includes a Demo page-builder theme. For an installation in which sites or subdomains can activate different themes, resolve Vihzhuo's theme from Devflow instead of hard-coding a folder in config/vihzhuo.php:

use App\Infrastructure\Services\Vihzhuo\VihzhuoTheme;

'theme' => [
    'class' => VihzhuoTheme::class,
    'folder' => public_path('themes'),
    'folder_url' => '/themes',
    'active_theme' => VihzhuoTheme::activeTheme(themeName: 'Demo'),
],

The fallback is used only when Devflow does not yet have an active theme. Once a site activates a theme, activeTheme() derives its folder slug from the stored identifier so the Website Manager, editor, public renderer, blocks, layouts, translations, and assets all use the same theme.