Skip to content

Page Builder / Block System

Flow ID: CF-35 | Module(s): builder | Complexity: High

Business Overview

The Page Builder is a modular content block system that enables administrators to create reusable, visually designed HTML/CSS/JS blocks using a GrapesJS-powered WYSIWYG editor. These blocks can be assigned to various storefront entity types on a per-language basis, replacing or augmenting the default description/content area for that entity.

Key capabilities:

  • Full drag-and-drop visual editor with 30+ pre-built block templates (hero sections, banner layouts, promo pages, navigation lists, card containers, headers)
  • Custom CSS, HTML, and JavaScript per block -- all three are stored and rendered independently
  • Per-language block assignment via MUI table foreign keys, enabling different visual content per locale
  • Configurable container wrapping per position (e.g., container, container-fluid, or no wrapper)
  • Asset management with file upload to files/builder/ via the editor's built-in asset manager
  • Block reuse across multiple entities: a single block can be linked to many products, categories, etc.
  • Clone, rename, and cascade-delete operations in the admin listing

Supported entity types (7 positions):

Position KeyEntityMUI TableFK ColumnContainer Wrap
productDescriptionProductshop_product_muibuilder_block_idNone
categoryDescriptionProduct Categoryshop_product_category_muibuilder_block_id<div class="container">
vendorDescriptionVendor (listing)shop_vendor_muibuilder_block_id<div class="container-fluid">
vendorExclusiveVendor (details)shop_vendor_muiexclusive_builder_block_idNone
blogArticleBlog Postblog_muibuilder_block_idNone
cmsPageCMS Pagecategories_muibuilder_block_id<div class="d-block custom-builder-pg">
promoPagePromo Pageshop_promo_muibuilder_block_id<div class="d-block custom-builder-pg">

Feature gate: Controlled by the BUILDER.ENABLED registry flag. When disabled, neither the admin menu entry nor storefront block rendering is active.


Admin Routes (Internal API)

There is no REST API for the builder. All operations are handled via legacy HMVC admin controllers.

Listing Routes (Admin Panel -- Admin_c)

RouteMethodControllerPurpose
GET /block-builder/listindexAdvBlockBuilderListingPaginated block list (all positions)
GET /block-builder/list/{position}indexAdvBlockBuilderListingFilter list by position key
GET /block-builder/list/{position}/{offset}indexAdvBlockBuilderListingPaginated, filtered list
GET /block-builder/clone/{id}cloneBlockAdvBlockBuilderListingDuplicate a block (new title, same content)
GET /block-builder/delete/{id}deleteBlockAdvBlockBuilderListingDelete with cascade across 7 MUI tables
POST /block-builder/rename/{id}renameBlockAdvBlockBuilderListingUpdate block title only

Editor Routes (Storefront Layout -- Front_c)

RouteMethodControllerPurpose
GET /block-builderbuildAdvBlockBuilderControllerCreate new block (GrapesJS editor)
GET /block-builder/{id}buildAdvBlockBuilderControllerEdit existing block (GrapesJS editor)
GET /block-builder/view/{id}viewAdvBlockBuilderControllerPreview block in storefront context
POST /block-builder/saveBlocksaveBlockAdvBlockBuilderControllerSave/update block data (GrapesJS remote storage)
GET /block-builder/loadBlock/{id}loadBlockAdvBlockBuilderControllerLoad block project data (GrapesJS remote storage)
POST /block-builder/saveAssetssaveAssetsAdvBlockBuilderControllerUpload files to files/builder/
GET /block-builder/getBlocksgetBlocksAdvBlockBuilderControllerJSON dropdown list for admin entity forms

All routes are language-prefixed (/{lang}/block-builder/...) and require admin authentication (isLoggedIn()).

The editor runs on the storefront layout (Front_c), not the admin layout. This is deliberate -- the GrapesJS canvas loads storefront CSS/JS (main.css, homepage.css, vendor.js) so blocks render exactly as they will appear to customers.


Code Flow: Storefront Rendering

When a customer visits a storefront page (product, category, vendor, blog, CMS, or promo), the rendering follows this path:

1. Controller Fetches Entity Data

Each entity controller queries its main table joined with the _mui table, which includes the builder_block_id column. For example, in Adv_products.php:

php
// The SELECT includes: shop_product_mui.builder_block_id
$this->render = $this->blockBuilder->frontRender(
    $this->render, $query->builder_block_id, 'productDescription'
);

2. AdvBlockBuilder::frontRender() Checks Feature Gate and Block ID

php
public function frontRender(array $render, ?int $blockId, ?string $position, ...): array
{
    if (!$this->enabled || !$blockId) {
        return $render;   // No-op if builder disabled or no block assigned
    }
    $block = $this->getData($blockId, $position);
    if ($block) {
        $render['blockData'][$block->key] = $block;  // Key = position name
    }
    return $render;
}

3. getData() Loads Block Content and Wraps with Container

php
public function getData(int $blockId, ?string $position = null, ...): ?BuilderBlockViewData
{
    $data = $this->model->get($blockId);  // SELECT * FROM builder_blocks WHERE id = ?
    if (!$data) return null;

    return new BuilderBlockViewData(
        key: $position,
        title: $data->title,
        css: $data->css,
        js: $data->js,
        html: $data->html,
        openContainer: $this->config->openContainer($position),
        closeContainer: $this->config->closeContainer($position),
    );
}

4. CSS Injected in <head> (main.php)

php
<?php if (!empty($blockData) && is_array($blockData)) { ?>
    <style>
        <?php foreach ($blockData as $blockItem) { echo $blockItem->css; } ?>
    </style>
<?php } ?>

5. HTML Rendered in Entity View Template

Each entity view extracts the block for its position and either replaces or augments the default content:

Product (product.php): Replaces the description tab content entirely.

php
$blockViewData = $blockData['productDescription'] ?? null;
if ($blockViewData) :
    echo $this->load->view($template->componentView('builder'), ['blockViewData' => $blockViewData]);
else :
    echo $productData->description;
endif;

Category (category_products_list.php): Replaces the header area (name, description, slider).

php
$blockViewData = $blockData['categoryDescription'] ?? null;
<?php if ($blockViewData) : ?>
    <?= $this->load->view($template->componentView('builder'), ['blockViewData' => $blockViewData], true) ?>
<?php endif; ?>
<?php if (!$blockViewData) : ?>
    <!-- Standard category header with name, slider, description -->
<?php endif; ?>

Vendor Details (vendor_details.php): Replaces the entire banner/details section with exclusive block.

php
$blockViewData = $blockData['vendorExclusive'] ?? null;
if (!$blockViewData) :
    <!-- Standard vendor banner layout -->
<?php else : ?>
    <?= $this->load->view($template->componentView('builder'), ['blockViewData' => $blockViewData], true); ?>
<?php endif; ?>

Vendor Listing (vendor_products_list.php): Replaces the listing header area.

Blog (blog_post.php): Replaces the entire article content.

CMS Page: If block exists, view switches to cmsBuilder layout; otherwise renders the standard page view.

Promo Page: If block exists, view switches to promoBuilder layout; otherwise uses the promo's view_type.

6. Shared Builder Component View

All positions render through the same component (components/builder/view.php):

php
echo $blockViewData->openContainer;   // Position-specific wrapper (or empty)
echo $blockViewData->html;            // Raw HTML from GrapesJS
echo $blockViewData->closeContainer;  // Closing wrapper tag

Block JS is executed after page load in footer_js.php:

php
<?php if (!empty($blockData) && is_array($blockData)) { ?>
    <script>
        <?php foreach ($blockData as $blockItem) { echo $blockItem->js; } ?>
    </script>
<?php } ?>

Per-Entity Rendering Summary

EntityBehavior When Block AssignedStorefront View File
ProductReplaces description tab contentlayouts/product_page/product.php
Product CategoryReplaces header area (name/description/slider)layouts/product_category/category_products_list.php
Vendor DetailsReplaces entire banner/details sectionlayouts/product_vendor/vendor_details.php
Vendor ListingReplaces listing headerlayouts/product_vendor/vendor_products_list.php
Blog ArticleReplaces entire article bodylayouts/blog/blog_post.php
CMS PageSwitches to cmsBuilder layoutlayouts/builder/cms.php
Promo PageSwitches to promoBuilder layoutlayouts/builder/promo.php

Architecture

Module Layer Structure

The builder uses the two-tier HMVC pattern: core classes in ecommercen/builder/, application-level thin subclasses in application/modules/builder/.

LayerComponentPathDescription
Core LibraryAdvBlockBuilderecommercen/builder/libraries/AdvBlockBuilder.phpMain business logic: enabled check, frontRender, getData, adminRender
Core ConfigAdvBuilderConfigecommercen/builder/libraries/AdvBuilderConfig.phpSingleton; loads application/config/builder.php, resolves container wrapping per position
Core ViewDataAdvBuilderBlockViewDataecommercen/builder/libraries/AdvBuilderBlockViewData.phpDTO: title, css, js, html, openContainer, closeContainer, key
Core ModelAdvBlockBuilderModelecommercen/builder/models/AdvBlockBuilderModel.phpDB layer: CRUD via GenericReadModel/GenericWriteModel traits, admin listing with position usage counts, cascade delete
Core EditorAdvBlockBuilderControllerecommercen/builder/controllers/AdvBlockBuilderController.phpGrapesJS editor, save/load endpoints, asset management. Extends Front_c
Core ListingAdvBlockBuilderListingecommercen/builder/controllers/AdvBlockBuilderListing.phpAdmin listing with position filter, clone, rename, delete. Extends Admin_c
App LibraryBlockBuilderapplication/modules/builder/libraries/BlockBuilder.phpThin subclass of AdvBlockBuilder
App ConfigBuilderConfigapplication/modules/builder/libraries/BuilderConfig.phpThin subclass of AdvBuilderConfig (singleton)
App ModelBuilder_modelapplication/modules/builder/models/Builder_model.phpThin subclass of AdvBlockBuilderModel

Frontend (GrapesJS Editor)

The GrapesJS editor is a standalone JS application compiled by Laravel Mix:

Entry point: assets/admin/js/builder/index.js -> public/ui/admin/dist/builder.js

Build config: webpack.mix.admin.js line 100

FilePurpose
editor/editor.jsInitializes GrapesJS with plugins, canvas styles, remote storage config, asset manager
editor/plugins.jsPlugin list: preset-webpage, blocks-basic, touch, style-bg, style-gradient, style-filter, custom-code
editor/commands.jsCustom commands: store-data, modal-title, open-url, change-bg-color, padding-settings, margin-settings, wrap-with-link, copy-style
editor/buttons.jsPanel buttons: back-to-panel (ecommercen logo), set-title, publish, exit
editor/toolbarButtons.jsPer-component toolbar: background color, padding, margin, wrap-with-link, copy-style
editor/rte.jsRich text enhancements: font size, font family, text color, text alignment, list style, text spacing (letter-spacing, line-height, vertical align), copy style, custom link modal
editor/assets.jsAsset manager config: upload to /block-builder/saveAssets, loads from window.builderEditor.assetManagerAssets
editor/i18n.jsi18n: Greek (default), English, Spanish, Italian, Russian, Chinese
editor/t.jsTranslation helper wrapping editor.I18n.t()
blocks/test-blocks.jsBlock registry: imports and registers all 30+ custom block templates
blocks/grapes-custom-slider.jsCustom GrapesJS component type for image sliders

GrapesJS Plugins:

Pluginnpm PackagePurpose
Preset Webpagegrapesjs-preset-webpageStandard webpage editing features
Blocks Basicgrapesjs-blocks-basicBasic layout blocks (flex grid enabled)
Touchgrapesjs-touchTouch device support
Style Backgroundgrapesjs-style-bgBackground style panel
Style Gradientgrapesjs-style-gradientGradient editor
Style Filtergrapesjs-style-filterCSS filter editor
Custom Codegrapesjs-custom-codeCustom HTML/CSS/JS component

Canvas Configuration: The editor canvas loads the storefront CSS and JS to provide WYSIWYG fidelity:

  • /ui/main/css/main.css, /ui/main/css/homepage.css
  • Slick Carousel CSS from CDN
  • /ui/main/js/vendor.js, /ui/main/js/vendors.js
  • Slick Carousel JS from CDN

Pre-built Block Templates (30+ blocks in blocks/ directory):

CategoryTemplates
Promo Pagespromo-page-1 through promo-page-6
Hero Sectionshero-cover-1, hero-cover-2, hero-cover-3, hero-animated-text-image, hero-section-w-text-cta
Item Containersblock-four-items-container, block-five-items-container, block-grid-container
Card Layoutshorizontal-cards-container, horizontal-cards-container-2, card-banners-block, cardItem-1, cardItem-2
Banner Layoutsgrid-banners-container, border-banners-container, image-banners-block, round-images-block, text-banners-block
Navigationhorizontal-nav-list, horizontal-nav-list-w-background, horizontal-nav-list-animated
Headersheader-1, header-2, header-3
Listslist-w-title
Custom Componentsgrapes-custom-slider (slider with prev/next navigation)

Storage Architecture

Remote Storage (GrapesJS storageManager):

  • Save: POST /block-builder/saveBlock -- sends id, css, html, js, title, data (GrapesJS project JSON)
  • Load: GET /block-builder/loadBlock/{id} -- returns { id, data } where data is the GrapesJS project JSON
  • Autosave is disabled; saving is manual via the "Publish" button
  • On first save (no blockId), a new row is inserted and the new ID returned to the editor via window.builderEditor.builderProjectId

Asset Upload:

  • POST /block-builder/saveAssets -- multipart file upload
  • Files stored to files/builder/ via AdvUploader::uploadFilesSameFolder()
  • File listing retrieved from Flysystem (storage()->listContents('files/builder/'))
  • URLs returned as assetUrl("files/builder/{filename}") for the GrapesJS asset manager

Admin Integration (Entity Forms)

Every entity admin controller (products, categories, vendors, blog, promo, CMS) integrates the builder via a consistent pattern:

  1. Controller instantiates BlockBuilder and calls adminRender() to inject builderBlocks (dropdown) and builderEnabled (flag) into the render array
  2. Create/Update views show a <select class="js-builder-select"> dropdown per language tab with action buttons:
    • Add (+): Opens a new builder editor tab
    • Edit (pencil): Opens the selected block in editor, stores return URL in localStorage
    • Refresh (reload): AJAX fetch of updated block list from /block-builder/getBlocks
    • Clear (x): Clears the selection
  3. Admin JS (footer_js.php) toggles visibility of standard description fields (js-no-builder-data) when a builder block is selected
  4. Save handler reads builder_block_id_{lang} from POST and stores it in the MUI row

Data Model

builder_blocks Table

The central table storing all block content:

ColumnTypeDescription
idINT (PK, AI)Block identifier
titleVARCHARAdmin-facing block title
contentLONGTEXTGrapesJS project JSON (component tree, styles, etc.)
htmlLONGTEXTCompiled HTML output from GrapesJS
cssLONGTEXTCompiled CSS output from GrapesJS
jsLONGTEXTCompiled JS output from GrapesJS

Foreign Key References (7 MUI columns across 6 tables)

MUI TableFK ColumnIndexMigration
shop_product_muibuilder_block_idYes20241031164345_add_product_block_id.php
shop_product_category_muibuilder_block_idYes20241031161307_add_product_category_block_id.php
shop_vendor_muibuilder_block_idYes20241031154616_add_vendor_block_ids.php
shop_vendor_muiexclusive_builder_block_idYes20241031154616_add_vendor_block_ids.php
blog_muibuilder_block_idYes20241031135126_add_blog_article_block_id.php
categories_muibuilder_block_idYes20241031133710_add_cms_block_id.php
shop_promo_muibuilder_block_idYes(pre-existing in table schema)

All FK columns are INT(11) DEFAULT NULL with non-unique indexes. There are no database-level foreign key constraints -- referential integrity is enforced by the application-level cascade delete in deleteWithRelations().

Admin Listing Query

The listing query joins builder_blocks with all 7 MUI tables via LEFT JOINs using subquery counts to show usage per position:

sql
SELECT t1.id, t1.title,
  COALESCE(t2.blogArticle, 0)       AS blogArticle,
  COALESCE(t3.cmsPage, 0)           AS cmsPage,
  COALESCE(t4.categoryDescription, 0) AS categoryDescription,
  COALESCE(t5.productDescription, 0)  AS productDescription,
  COALESCE(t6.promoPage, 0)         AS promoPage,
  COALESCE(t7.vendorDescription, 0) AS vendorDescription,
  COALESCE(t8.vendorExclusive, 0)   AS vendorExclusive
FROM builder_blocks t1
  LEFT JOIN (SELECT builder_block_id, COUNT(*) ... FROM blog_mui ...) t2 ...
  LEFT JOIN (SELECT builder_block_id, COUNT(*) ... FROM categories_mui ...) t3 ...
  LEFT JOIN (SELECT builder_block_id, COUNT(*) ... FROM shop_product_category_mui ...) t4 ...
  LEFT JOIN (SELECT builder_block_id, COUNT(*) ... FROM shop_product_mui ...) t5 ...
  LEFT JOIN (SELECT builder_block_id, COUNT(*) ... FROM shop_promo_mui ...) t6 ...
  LEFT JOIN (SELECT builder_block_id, COUNT(*) ... FROM shop_vendor_mui ...) t7 ...
  LEFT JOIN (SELECT exclusive_builder_block_id, COUNT(*) ... FROM shop_vendor_mui ...) t8 ...

Position filtering uses WHERE t{N}.{positionKey} > 0 to show only blocks used in a specific position.


Configuration

Builder Config (application/config/builder.php)

Defines all valid positions and their container wrapping behavior:

php
$config['builder'] = [
    'positions' => [
        'promoPage'           => ['openContainer' => '<div class="d-block custom-builder-pg">', 'closeContainer' => '</div>'],
        'productDescription'  => ['openContainer' => null, 'closeContainer' => null],
        'categoryDescription' => ['openContainer' => '<div class="container">', 'closeContainer' => '</div>'],
        'vendorDescription'   => ['openContainer' => '<div class="container-fluid">', 'closeContainer' => '</div>'],
        'vendorExclusive'     => ['openContainer' => null, 'closeContainer' => null],
        'blogArticle'         => ['openContainer' => null, 'closeContainer' => null],
        'cmsPage'             => ['openContainer' => '<div class="d-block custom-builder-pg">', 'closeContainer' => '</div>'],
    ],
    'defaultOpenContainer' => '',
    'defaultCloseContainer' => '',
];

Container wrapping rules:

  • null = use defaultOpenContainer/defaultCloseContainer (empty string = no wrapper)
  • Empty string '' = explicitly no container
  • Any string = use that HTML as the container wrapper

Registry Flag

GroupKeyValuesEffect
BUILDERENABLED1 / 0Controls admin menu visibility, admin form dropdown visibility (builderEnabled), and storefront rendering (frontRender no-ops when disabled)

Admin Menu Entry

php
[
    'route' => 'block-builder/list',
    'icon' => t('admin.menu.builder.icon'),
    'label' => t('admin.menu.builder.label'),
    'roles' => [AUTH_ROLE_ADVISABLE, AUTH_ROLE_ADMIN, AUTH_ROLE_MARKETING, AUTH_ROLE_MEDIA],
    'group' => 'CONTENT'
]

The builder is accessible to Advisable, Admin, Marketing, and Media roles.

Template Config (application/config/mainTemplate.json / template.json)

Registers the shared builder component view:

json
"builder": {
    "view": "components/builder/view",
    "scss": ""
}

Resolved by $template->componentView('builder') at render time.


Client Extension Points

Config Override (application/config/builder.php)

Client repos can modify position definitions, add new positions, or change container wrapping by overriding this file. Adding a new position also requires:

  1. A builder_block_id column in the target MUI table (migration)
  2. A frontRender() call in the storefront controller
  3. A view template conditional to render the block
  4. Updating deleteWithRelations() to nullify the new FK on cascade delete
  5. Updating the admin listing SQL subqueries and filterPosition() match cases

BlockBuilder / BuilderConfig Subclasses

Client repos override behavior via thin subclasses in application/modules/builder/libraries/:

  • BlockBuilder extends AdvBlockBuilder -- override frontRender(), getData(), or adminRender() for custom behavior
  • BuilderConfig extends AdvBuilderConfig -- override container resolution logic

View Templates

Client repos can customize how blocks appear per entity type by overriding the storefront view files in application/views/main/layouts/. The shared component view (components/builder/view.php) can also be overridden.

Asset Storage

Uploaded assets go to files/builder/. Clients can customize storage (local, S3, SFTP) via the Flysystem configuration since asset listing and URLs use the storage() helper and assetUrl().

Admin Form Integration

The builder dropdown appears in admin entity forms when builderEnabled is true. Client repos can customize the admin views to change placement or add additional builder-related controls.

GrapesJS Block Templates

Custom block templates are compiled into builder.js. Clients wanting additional templates would need to add block JS files and rebuild via npm run admin-production. The block template system uses GrapesJS's editor.Blocks.add() API with inline HTML/CSS content.


Business Rules

  1. Feature gating: When BUILDER.ENABLED is 0, frontRender() returns the render array unchanged, and admin forms hide the block dropdown. The admin menu entry is still registered but the dropdown/editor is not functional.

  2. Block content precedence: When a block is assigned to an entity, it replaces (not supplements) the default content area for that entity. For products, the block replaces the description tab. For categories and vendors, it replaces the header/description section. For CMS and promo pages, it switches the entire layout to a builder-specific layout file.

  3. Per-language assignment: Blocks are assigned per language via the MUI table's builder_block_id column. Different languages can have different blocks (or no block) for the same entity. However, block content itself is not language-aware -- the same HTML/CSS/JS is rendered regardless of language.

  4. One block per position per entity: Each MUI row can reference at most one builder_block_id. To display multiple blocks, the block itself must contain all desired content.

  5. Block reuse: A single block can be referenced by multiple entities across different positions. The admin listing shows usage counts per position.

  6. Cascade delete: deleteWithRelations() nullifies all FK references across all 7 MUI columns before deleting the block row, all within a database transaction. This does not delete the entities themselves -- it simply removes the block association, causing those entities to fall back to their default content rendering.

  7. Clone behavior: Cloning creates a new block with a random title ("Titlos Block {random}") and copies html, css, js, and content (GrapesJS project data). The clone has no entity associations -- it must be manually linked to entities.

  8. Editor authentication: The GrapesJS editor runs on Front_c (storefront layout) but requires isLoggedIn() (admin session). This allows the canvas to use storefront CSS/JS for WYSIWYG accuracy.

  9. Unsaved changes protection: The editor tracks undo history via GrapesJS's UndoManager. When exiting with unsaved changes, a modal prompts to save or discard. The return URL is stored in localStorage as linkBeforeBuilder.

  10. HTML sanitization: The saveBlock endpoint strips <body> tags and the default GrapesJS box-sizing reset from the CSS. No further sanitization is applied -- admin users are trusted with raw HTML/CSS/JS.

  11. Admin JS field toggling: When a builder block is selected in an entity admin form, standard description fields (marked with js-no-builder-data class) are hidden to avoid confusion about which content will be displayed.


Note: CMS page blocks (cmsPage position) replace category-based CMS page content. This operates through the categories module, not the documents module.

See also the Builder guide for setup instructions and usage reference.