menu_book Navigation menu

PHP Hooks (Action & Filters)

info
Hooks are the primary way to extend Pragma CMS without modifying the core.
They exist in two environments:
  • Server-Side (PHP): For data logic, saving, and rendering HTML.
  • Client-Side (JS): For the Admin UI, Visual Builder tools, and Media Manager.

Use the Hook enum class (Hook::) to reference these hooks safely.

Core & Admin System Hooks

Filter Hooks (Modify Data)

These hooks expect you to return a modified value.

Hook NameConstant (Hook::)Description
ADMIN_ENQUEUE_JSADMIN_ENQUEUE_JSModify JS files loaded on Admin pages (e.g., adding a new Editor.js tool).
ADMIN_ENQUEUE_CSSADMIN_ENQUEUE_CSSModify CSS files loaded on Admin pages.
REGISTER_DASHBOARD_WIDGETSREGISTER_DASHBOARD_WIDGETSAdd, remove, or reorder widgets on the admin dashboard.
ADMIN_STATISTICS_SECTIONSADMIN_STATISTICS_SECTIONSAdd custom sections (e.g., iFrames) to the Statistics page.
BODY_ENDBODY_ENDInject HTML/Scripts just before the </body> tag on the front-end.

Code Example: Registering a CSS file for single entry pages

PHP
<?php

/**
 * Adds the theme's base styles for pages of type 'single'.
 *
 * @param array $cssFiles The array of already registered CSS files.
 * @param object $page The current page object.
 * @return array The updated array of CSS files.
 */
function portfolioTheme_addSingleEntryStyles(array $cssFiles, $page): array {
    
    // Add our file to the existing array.
    // Using [] ensures we ADD and do not OVERWRITE.
    $cssFiles[] = [
        "href" => getAssetsPath("vendors/fontawesome-7/css/all.min.css", isTheme: true)
    ];
    
    // IMPORTANT: Always return the modified array.
    return $cssFiles;
}

// Tell HooksManager: "When 'enqueue_single_entry_css' is triggered,
// call the function 'portfolioTheme_addSingleEntryStyles'." 
// The '10' is the priority (standard).
Hooks::addFilter(Hook::ENQUEUE_SINGLE_ENTRY_CSS, 'portfolioTheme_addSingleEntryStyles', 10);

?>

Code Example: Dashboard Widgets

PHP
<?php

Hooks::addFilter(Hook::REGISTER_DASHBOARD_WIDGETS, function(array $widgets) {

    ob_start(); ?>

    <div id="widget-custom-stats" class="card-admin span-2">
        <h2>🛒 Store</h2>

        <div class="stats-grid">
            <div class="stat-item highlight">
                <span class="label">Today's Sales</span>
                <span class="value">14</span>
            </div>
        </div>
    </div>

    <?php

    $widgets['my-extension-stats'] = [
        'html' => ob_get_clean()
    ];

    return $widgets;
});

Action Hooks (Trigger Logic)

These hooks do not return data.

Hook NameConstant (Hook::)Description
AFTER_ENTRY_SAVEAFTER_ENTRY_SAVETriggered after a successful save. Use for cache clearing, webhooks, or logging.
REGISTER_SYSTEM_NOTIFICATIONSREGISTER_SYSTEM_NOTIFICATIONSInject warnings or info messages into the admin dashboard (e.g. "Missing API Key").
POST_SITE_UPDATEPOST_SITE_UPDATEFired after updating a site to a new CMS version. Useful for re-indexing, publishing assets, or migrations.
RENDER_HEADRENDER_HEADInject scripts or styles into the <head> of the public site.

Code Example: Enqueueing a CSS file globally

PHP
// Add flag CSS via a hook, only if the theme/extension is active
Hooks::addAction(Hook::ENQUEUE_CSS, function($page) {
    // Add flag CSS only if the theme/extension is active
    $page->cssFiles[] = [
        "href" => getAssetsPath("icons/Flag-Icons/flag-icons.min.css", isTheme: true)
    ];
});

Code Example: System Notifications

PHP
Hooks::addAction(Hook::REGISTER_SYSTEM_NOTIFICATIONS, function (bool $isPlatform) {
    if (defined('APP_ENV') && APP_ENV === 'production' && defined('DEBUG') && DEBUG) {
        NotificationManager::add(
            'warning',
            getTranslation('Debug mode is enabled in production'),
            $isPlatform ? getPlatformLink('settings') : null
        );
    }
});

System & Configuration

These hooks allow you to modify the behavior of the CMS core and the global administration environment.

Hook (Enum)TypeDescription
SETTINGS_SCHEMAFilterHigh Level: Define custom settings fields. Handles both UI rendering and automatic data processing (encryption/sanitization).
SETTINGS_BEFORE_SAVEFilterLow Level: Escape hatch triggered before saving. Use this to validate API keys, format complex arrays to JSON, or trigger side-effects (e.g., clearing cache).
REGISTER_SYSTEM_NOTIFICATIONSActionInject warnings or info messages into the admin dashboard (e.g., "Missing API Key").
ADMIN_SIDEBAR_MENUFilterAdd, modify, remove, or reorder custom items in the main admin sidebar using a structured menu array (labels, icons, permissions, routes, order, etc.).

Code Example: Adding a Stripe Billing section

The SETTINGS_SCHEMA hook is the "Single Source of Truth" for settings. You don't need to write any HTML or SQL saving logic; the Core does it for you based on the types you define.

PHP
<?php

Hooks::addFilter(Hook::SETTINGS_SCHEMA, function(array $schema) {

    // Add a new "Billing" group
    $schema['billing'] = [
        'title' => getTranslation('Billing & Payments'),
        'fields' => [
            'stripe_publishable_key' => [
                'type'  => 'text',
                'label' => 'Stripe Publishable Key'
            ],
            'stripe_secret_key' => [
                'type'  => 'password', // Automatically encrypted in DB and hidden in UI
                'label' => 'Stripe Secret Key'
            ],
            'currency' => [
                'type'    => 'select',
                'label'   => 'Default Currency',
                'options' => ['EUR' => 'Euro', 'USD' => 'US Dollar']
            ]
        ]
    ];

    return $schema;
});

Code Example: Validating data before saving

If you need to verify that the Stripe Secret Key provided by the user is actually valid before saving it to the database, use SETTINGS_BEFORE_SAVE.

PHP
Hooks::addFilter(Hook::SETTINGS_BEFORE_SAVE, function(array $updates, array $postData) {
    
    // Check if the user is trying to save a new Stripe Key
    if (!empty($postData['stripe_secret_key'])) {
        $isValid = StripeApi::verifyKey($postData['stripe_secret_key']);
        
        if (!$isValid) {
            // Log the error to the UI and remove the key from the update array
            logError("The provided Stripe Secret Key is invalid.", forUser: true);
            unset($updates['stripe_secret_key']); 
        }
    }

    return $updates;
});

Content & Entries

Hook (Enum)TypeDescription
BEFORE_ENTRY_SAVEFilterModify entry data before it is written to the database. Useful for validation or auto-formatting.
AFTER_ENTRY_SAVEActionTriggered after a successful save. Use for cache clearing, webhooks, or logging.
ON_OBJECT_DELETEActionFired when an object (entry, media) is deleted. Clean up relations (e.g. taxonomy terms).
GET_RECOMMENDED_CONTENTFilterOverride or enrich the recommended content for a given entry.
ON_OBJECT_UPDATE_RECOMMENDATIONSActionFired when an object's related recommendations are updated.
GET_COMMENTS_FOR_ENTRYFilterRetrieve or modify the comment list (useful for integrating external systems).
RENDER_EDITOR_BLOCKFilterCritical: Transform Editor.js JSON block data into HTML. Used by the frontend renderer.

Code Example: Rendering Editor Block

PHP
Hooks::addFilter(Hook::RENDER_EDITOR_BLOCK, function($html, $type, $data) {
    if ($type === 'myCustomTool') {
        return '<div class="my-custom-block">' . htmlspecialchars($data['someData'] ?? '') . '</div>';
    }
    return $html;
});

Taxonomies

Manage how terms are linked, saved, or rendered.

Hook (Enum)TypeDescription
AFTER_HYDRATE_TAXONOMIESFilterCalled after taxonomies have been added to each post. Modify, filter or enrich before frontend use.
TAXONOMY_TERM_PAGE_HANDLERSFilterProvide handlers (public name, pagination, callbacks) for taxonomy term pages.
ON_OBJECT_UPDATE_TERMSActionFired when an object's taxonomy terms are updated.
ON_TAXONOMY_TERM_SAVEActionFired when a taxonomy term is created or updated. Ideal for Sitemaps or Cache purging.
ON_TAXONOMY_TERM_DELETEActionFired when a taxonomy term is deleted.

Code Example: Hydrate Taxonomies: after_hydrate_taxonomies

This hook is called after taxonomies have been added to each post. It allows you to modify, filter or enrich the taxonomies before they are used by the application.

PHP
$entries = Hooks::applyFilters(Hook::AFTER_HYDRATE_TAXONOMIES, $entries, $contentType, $langId);

Example of use:

PHP
Hooks::addFilter(Hook::AFTER_HYDRATE_TAXONOMIES, function($entries, $contentType, $langId) {
    foreach ($entries as &$entry) {
        // Add a dynamically calculated tag   
        $entry['taxonomies'][] = 'custom-tag';    
    }
    return $entries;}, 10, 3);

Code Example: Taxonomy Page Handlers

PHP
Hooks::addFilter(Hook::TAXONOMY_TERM_PAGE_HANDLERS, function(array $handlers): array {
    $handlers['article'] = [
        'public_name' => 'Articles',
        'items_per_page' => 5,
        'count_callback' => 'get_articles_count_by_ids',
        'fetch_callback' => 'get_articles_data_by_ids',
        'render_card_callback' => 'render_article_card_for_taxonomy'
    ];
    return $handlers;
});

Fields (Custom Fields Pipeline)

Pragma CMS allows developers to create custom fields. We use a Single Source of Truth approach: you define the UI, the Database Storage, and the Security rules in one single hook pipeline.

1. Definition and Storage (FIELD_REGISTRY)

This step is unique and allows you to declare your custom field to the CMS whilst defining its behaviour in the database.

This filter allows you to add your field to the list of types available in the builders and immediately define how it will be stored (SQL). This is the first step in ensuring that your field is recognised by the CMS.

PHP
Hooks::addFilter(Hook::FIELD_REGISTRY, function(array $registry) {
    // Definition of identity and SQL storage
    $registry['google_maps_address'] = [
        'label'    => 'Google Maps Address', // Name in the interface
        'icon'     => 'place',               // Material Symbol icon
        'group'    => 'Special Fields',      // Category in the palette
        'sql'      => 'JSON NULL',           // SQL type for Collections/Structures
        'storage'  => 'json',                // Target column for Singles (text, number, json, etc.)
        'sanitize' => 'json'                 // Sanitization strategy (string, int, bool, html, json)
    ];
    return $registry;
});

UI Properties

  • label: Display name shown in the field picker interface.
  • icon: Material Symbol identifier displayed for the field.
  • group: Category used to organize the field in the builder palette.

Storage Properties

  • sql: Raw SQL definition used for CREATE or ALTER table commands.
  • storage: For Single content types, defines which column of the entries_fields table stores the data.
  • sanitize: Defines the cleaning strategy before saving.
    • Available sanitize strategies: string (default), intfloatbool, datehashslughtmljsonstrict_jsondeep_json.

Optional Behaviour Properties

  • is_subfield_compatible (optional): Defaults to true. Set to false to prevent the field from being used as a nested subfield.

2. Contextual Configuration (FINALIZE_CONTENT_TYPE_FIELD)

After saving, you can fine-tune the configuration of a field differently depending on whether it is used in a Content Type or a Form.

  • Configuration Hooks (Filters)
    These filters are called just after submission of the create/edit form (for a Content Type or a Form), for each field. They allow you to validate, sanitize, or enrich the configuration of a field before it is saved.
    Note: While Content Types have access to the full suite of fields, the Form Builder provides a curated subset of fields specifically tailored for public data collection.
    • FINALIZE_CONTENT_TYPE_FIELD: For fields in a Content Type.
    • FINALIZE_FORM_FIELD: For fields in the Form Builder.

Example: Let’s add a default option only when our field is used in a Content Type.

PHP
Hooks::addFilter(Hook::FINALIZE_CONTENT_TYPE_FIELD, function($processedField, $rawFieldData) {
    // Only target our custom field in this context
    if ($processedField['type'] === 'google_maps_address') {
        // Add a default zoom option if it is not defined.
        // This option will exist only for Content Type fields.
        if (!isset($processedField['options']['default_zoom'])) {
            $processedField['options']['default_zoom'] = 14;
        }
    }
    // Always return the configuration array, modified or not.
    return $processedField;
});

3. Admin Rendering (RENDER_CUSTOM_FIELD_HTML)

These hooks define how the field is displayed in the admin interface and how the data is handled before saving.

This filter allows you to provide custom HTML for displaying the field in admin forms (content creation/editing).

PHP
Hooks::addFilter(Hook::RENDER_CUSTOM_FIELD_HTML, function($html, $handle, $config, $currentValue, $namePrefix) {

    // Check if it's our Google Maps custom field
    if ($config['type'] === 'google_maps_address') {

        $fieldName = "{$namePrefix}[{$handle}]";
        $id = "field-" . htmlspecialchars(
            str_replace(['[', ']'], ['-', ''], $fieldName)
        );

        ob_start(); ?>
        
        <div class="form-container">
            <fieldset class="fieldset-card field-type-google-maps" id="container-<?= $id ?>">
                
                <legend class="form-label" for="<?= $id ?>">
                    <?= htmlspecialchars($config['label']) ?>
                </legend>

                <input 
                    type="text"
                    id="<?= $id ?>"
                    name="<?= htmlspecialchars($fieldName) ?>"
                    value="<?= htmlspecialchars($currentValue ?? '') ?>"
                    class="form-control google-maps-autocomplete-input"
                    placeholder="Search an address..."
                >

                <small class="form-help">
                    Start typing to search for an address using Google Maps.
                </small>

            </fieldset>
        </div>

        <?php
        $html = ob_get_clean();
    }

    return $html;

}, 10); // 10 = priority

4. Data Processing (PROCESS_CUSTOM_FIELD_VALUE)

Pragma CMS uses two distinct filters to process data depending on whether it comes from an administrative entry or a public form submission.

A. Content Types (Back-office)

This filter intercepts data for Custom Fields in Content Types before it is saved to the database. It is ideal for server-side formatting or API calls during content creation.

PHP
Hooks::addFilter(Hook::PROCESS_CUSTOM_FIELD_VALUE, function($value, $handle, $config) {
    if ($config['type'] === 'google_maps_address' && is_string($value)) {
        // 1. Call the Google Geocoding API with the address ($value)       
        $geoData = callGoogleApi($value); // Hypothetical function        

        // 2. Return a structured array that will later be encoded as JSON        
        if ($geoData) {
            return [
                'address' => $value,                
                'lat' => $geoData['lat'],                
                'lng' => $geoData['lng']
            ];        
        }
    }

    // If it's not our field, return the original value unchanged    
    return $value;
}, 10);

B. Public Form Submissions (Front-office)

When using the Form Builder, you can intercept the submitted data for any field (native or custom). This is useful for late-stage validation, encryption, or cleaning data specifically for leads and contact requests.

PHP
// Processing for Form Builder submissions
Hooks::addFilter(Hook::FORM_PROCESS_FIELD_VALUE, function(array $result, array $fieldConfig) {
    // $result contains ['handle' => '...', 'value' => '...']
    
    if ($fieldConfig['field_type'] === 'phone') {
        // Example: Force international format for all phone submissions
        $result['value'] = formatToInternational($result['value']);
    }

    return $result;
});

5. Data Hydration (HYDRATE_FIELD_VALUE)

This filter lets you intercept a value after it has been retrieved from the database and before it is used by the application (e.g., passed to a template). It’s the ideal place to enrich the data, make API calls, or modify its structure.

Example: Our google_maps_address field is stored as JSON with the address, latitude, and longitude. We want to add the current weather for that location each time an entry is loaded.

PHP
Hooks::addFilter(Hook::HYDRATE_FIELD_VALUE, function($value, $config, $entry) {
    // Check that it's our field and that the value is an array (already decoded from JSON by the system)    
    if ($config['type'] === 'google_maps_address' && is_array($value) && isset($value['lat'])) {
        // 1. Call a weather API with the coordinates        
        // A caching system should be used here to avoid overloading the API        
        $weatherData = getWeatherForCoordinates($value['lat'], $value['lng']); // Hypothetical function        

        // 2. Enrich the value by adding weather data        
        if ($weatherData) {
            $value['weather'] = [
                'temperature' => $weatherData['temp_celsius'],                
                'description' => $weatherData['summary']
            ];        
        }
    }

    // Always return the value (modified or not)    
    return $value;
}, 10, 3);

Template Usage Result:

Thanks to this strict pipeline, front-end developers receive a fully hydrated, safe-to-use object directly in their views:

PHP
// In a PHP template (e.g., home.php)
// Access the 'entry' array from the $page object: $page->entry["fields"]['my_address_field'];
$adresse = $page->entry["fields"]['mon_champ_adresse'];

echo htmlspecialchars($adresse['address']); // Displays "1600 Amphitheatre Parkway, Mountain View, CA"  
echo htmlspecialchars($adresse['map_url']);
echo htmlspecialchars($adresse['lat']);   // Displays "37.422"       
echo htmlspecialchars($adresse['weather']['temperature']); // Displays "15°C"

Specific Field Implementations

Icon

Adding a custom icon library (FontAwesome, Lucide, etc.)

You can register any third-party icon library, in your theme for example, by using the JS method window.CMS.icons.registerProvider.

Step 1: Enqueue resources (PHP)

In your theme’s functions.php file, use the CMS hooks to inject the CSS files (for styling) and JS files (for the selector logic) only in the admin area.

PHP
/**
 * Loading FontAwesome resources in the admin panel
 */
Hooks::addFilter(Hook::ADMIN_ENQUEUE_CSS, function($page) {
    // Load the CSS to view the icons in the gallery
    $page->cssFiles[] = [
        'href' => getAssetsPath("vendors/fontawesome/css/all.min.css", isTheme: true)
    ];
    return $page;
});

Hooks::addFilter(Hook::ADMIN_ENQUEUE_JS, function($page) {
    // Load the JS script for your new provider
    $page->jsFiles[] = [
        'src' => getAssetsPath("js/fontawesome-provider.js", isTheme: true), 
        'defer' => true
    ];
    return $page;
});

Step 2: Register the Provider (JS)

Create the relevant JavaScript file in your theme (e.g. assets/js/fontawesome-provider.js) and use the window.CMS.icons.registerProvider method.

JAVASCRIPT
/**
 * Enregistrement du provider FontAwesome 
 */
window.CMS.icons.registerProvider('fontawesome', 'Font Awesome 7', {
    
    // Chemin vers le catalogue JSON des icônes de la librairie
    path: window.CMS.baseUrl + "assets/themes/mon-theme/assets/vendors/fontawesome/icons.json",

    // Charge la liste des icônes
    async load() {
        const response = await fetch(this.path);
        const jsonData = await response.json();
        
        // Retourne un tableau d'objets { name, styles }
        return Object.keys(jsonData).map(iconName => ({
            name: iconName,
            styles: jsonData[iconName].styles 
        }));
    },

    // --- Affiche l'icône dans la grille de sélection --- 
		render(iconData, callback) {
		    const item = document.createElement('div');
		    item.className = 'grid-icon-item';
		
		    // --- Détermination du style et des noms ---
		    // On choisit un style par défaut. 'solid' est le plus courant.
		    // Si 'solid' n'est pas dispo, on prend le premier style de la liste.
		    const style = iconData.styles.includes('solid') ? 'solid' : iconData.styles[0];
		    const libraryKey = `fa-${style}`;          // ex: fa-solid
		    const iconName = iconData.name;            // ex: house
		    const iconClassName = `fa-${style} fa-${iconName}`;
		
		    // --- Affichage de l'icône dans la grille ---
		    item.innerHTML = `
		        <i class="${iconClassName}"></i>
		        <div class="icon-name">${iconName}</div>
		    `;
		
		    // --- Action au clic : transmet les données au CMS ---
		    item.addEventListener('click', () => {
		        const iconDataForForm = {
		            html: `<i class="${iconClassName}"></i>`, // Pour l'aperçu immédiat
		            library: libraryKey,                      // Stocké en BDD (ex: fa-solid)
		            name: iconName                             // Stocké en BDD (ex: house)
		        };
		        callback(iconDataForForm);
		    });
		
		    return item;
			}
});

Step 3: Front-end rendering (PHP)

To display the icon on your public website, use the renderIcon() helper provided by the CMS in your Twig or PHP templates.

Twig example:

PHP
{# Retrieves the icon for a menu or content field #}
{{ renderIcon(item.icon_library, item.icon_name) }}

Gallery

You can add extra fields directly inside Gallery items.

  • Hook for adding custom fields to gallery items (render_gallery_item_fields)
    This hook allows you to add additional fields to each gallery item (for example: category, tag, caption, etc.).
PHP
Hooks::addAction('render_gallery_item_fields', function($index, $baseName, $item) {

    $key = 'category';
    $value = $item[$key] ?? '';

    echo "
        <div class='form-input-group'>
            <span>" . getTranslation('Category') . "</span>
            <input
                type='text'
                name='{$baseName}[{$index}][{$key}]'
                value='" . htmlspecialchars($value) . "'
            >
        </div>
    ";
});
  • Processing hook (process_custom_field_value)
    Use this hook to sanitize and validate your custom “category” field before saving.
PHP
Hooks::addFilter('process_custom_field_value', function($value, $handle, $config) {

    if ($config['type'] === 'gallery' && is_array($value)) {

        // $value contains all gallery items
        foreach ($value as &$item) {

            $key = 'category';

            if (isset($item[$key])) {

                // Sanitize the category value
                $item[$key] = trim(strip_tags($item[$key]));
            }
        }

        unset($item);
    }

    return $value;

}, 10);
  • Hydration hook (hydrate_field_value)
    Use this hook to transform the “category” data (for example, an ID) into a complete object or enriched structure.
PHP
Hooks::addFilter('hydrate_field_value', function($value, $config, $entry) {

    if ($config['type'] === 'gallery' && is_array($value)) {

        // $value contains gallery items already decoded from JSON
        foreach ($value as &$item) {

            // Enrich the item here
            // Example:
            // $item['category_object'] = getCategoryData($item['category']);
        }

        unset($item);
    }

    return $value;

}, 10);

Pages

Creating a Custom Editor.js Tool (JavaScript)

Imagine you want to create a simple “Google Maps” plugin. You can add a script like this:

Example file: components/editorjs/editor-map.js

JAVASCRIPT
/**
 * ============================================================================
 * EXAMPLE: CREATING A CUSTOM TOOL (SIMPLE GOOGLE MAPS)
 * ============================================================================
 *
 * This file demonstrates how to add a new tool to the content editor.
 * It follows the standard Editor.js API and integrates with the CMS plugin system.
 */

(function() { // IIFE to isolate scope and avoid variable conflicts

    /**
     * MapTool class.
     * Must implement render() and save() methods.
     */
    class MapTool {

        /**
         * Toolbox configuration ("+" menu).
         * @return {object} - Title and SVG icon.
         */
        static get toolbox() {
            return {
                title: 'Google Map',
                // SVG icon (location pin)
                icon: '<svg width="20" height="20" viewBox="0 0 24 24"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/></svg>'
            };
        }

        /**
         * Constructor called when the block is initialized.
         */
        constructor({data, config, api}) {
            this.data = {
                lat: data.lat || '',
                lng: data.lng || '',
                zoom: data.zoom || 12
            };
            this.apiKey = config.apiKey || '';
            this.wrapper = undefined;
        }

        /**
         * Main method: builds the block UI in the admin.
         */
        render() {
            this.wrapper = document.createElement('div');
            this.wrapper.classList.add('custom-map-tool');
            this.wrapper.style.padding = '15px';
            this.wrapper.style.border = '1px solid #eee';
            this.wrapper.style.backgroundColor = '#f9f9f9';

            this.wrapper.innerHTML = `
                <div style="font-weight:bold; margin-bottom:10px; color:#555;">📍 Map Settings</div>
                <div style="display:flex; gap:10px;">
                    <input type="text" class="cdx-input map-lat" placeholder="Latitude (e.g. 48.8566)" value="${this.data.lat}">
                    <input type="text" class="cdx-input map-lng" placeholder="Longitude (e.g. 2.3522)" value="${this.data.lng}">
                </div>
                <div style="margin-top:10px; font-size:0.8em; color:#888;">
                    API Key: ${this.apiKey ? 'Yes (Hidden)' : 'Not defined'}
                </div>
            `;

            return this.wrapper;
        }

        /**
         * Save method: extracts data from the DOM into JSON.
         */
        save(blockContent) {
            const latInput = blockContent.querySelector('.map-lat');
            const lngInput = blockContent.querySelector('.map-lng');

            return {
                lat: latInput.value,
                lng: lngInput.value,
                zoom: this.data.zoom
            };
        }
    }

    // Register tool in CMS
    window.CMS = window.CMS || {};
    window.CMS.editorTools = window.CMS.editorTools || {};

    window.CMS.editorTools.map = {
        class: MapTool,
        tunes: ['containerTune'],
        inlineToolbar: true,
        config: {
            apiKey: 'AIzaSyD...'
        }
    };

})();

Registering the Tool in the CMS (PHP Hooks)

Add the JS script to the Pages CRUD:

PHP
Hooks::addFilter(Hook::ADMIN_ENQUEUE_JS, function($page) {
    // Load only on create/edit pages
    if ($page->currentRoute["handle"] === "admin.pages.create" || $page->currentRoute["handle"] === "admin.pages.edit") {
        $page->jsFiles[] = [
            "src" => getAssetsPath(
                "js/components/editorjs/map-tool.js",
                isTheme: false,
                extensionName: "my-extension"
            ),
            "defer" => true
        ];
    }

    return $page;
});

You can also add CSS using the same approach (ADMIN_ENQUEUE_CSS).

Rendering the saved block (frontend)

PHP
Hooks::addFilter(Hook::RENDER_EDITOR_BLOCK, function($html, $type, $data) {
    if ($type === 'myCustomTool') {
        return '<div class="my-custom-block">' . htmlspecialchars($data['someData']) . '</div>';
    }
    return $html;
});

Forms

1. Hooks PHP (Backend)

Use the Hooks class and the Hook enum to interact with the form lifecycle.

Hook (Enum)TypeDescription
FORM_BUILDER_PALETTEFilterAllows you to add new field types to the admin field palette (for example: a “Map” or “Rating” field).
FORM_BEFORE_VALIDATIONFilterTriggered before validation starts. Useful for sanitizing or formatting raw data (for example: normalizing a phone number).
FORM_CUSTOM_VALIDATIONFilterAllows you to add advanced business validation rules and return custom validation errors (for example: checking if an email already exists in the users database).
FORM_PROCESS_FIELD_VALUEFilterModifies the final field value right before saving (for example: encrypting sensitive data or transforming text to uppercase).
FORM_AFTER_SUBMISSIONActionThe most important hook. Triggered after a successful submission save (and SQL transaction). Ideal for third-party integrations such as Zapier, Mailchimp, Slack, or CRM systems.

Example: Mailchimp integration for a newsletter signup

PHP
Hooks::addAction(Hook::FORM_AFTER_SUBMISSION, function($submissionId, $data, $formConfig, $files) {

    // Only trigger for the "newsletter" form (adjust as needed)
    if ($formConfig['form_handle'] !== 'newsletter') {
        return;
    }

    // Retrieve Mailchimp settings stored in your CMS configuration
    global $site; 
    $apiKey = $site->settings['mailchimp_api_key'] ?? '';
    $listId = $site->settings['mailchimp_list_id'] ?? '';

    // Basic safety checks
    if (!$apiKey || !$listId) {
        logError("Mailchimp: Missing API key or list ID in settings.");
        return;
    }

    if (empty($data['email']) || !filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
        logError("Mailchimp: Invalid or missing email for submission $submissionId.");
        return;
    }

    // Extract datacenter from API key (e.g. xxxx-us8 → us8)
    $dc = substr($apiKey, strpos($apiKey, '-') + 1);

    // Member hash used by Mailchimp to uniquely identify a subscriber
    $memberHash = md5(strtolower($data['email']));

    // Mailchimp "PUT" endpoint (Create or Update subscriber)
    $url = "https://{$dc}.api.mailchimp.com/3.0/lists/{$listId}/members/{$memberHash}";

    // Payload (use "status_if_new" with PUT)
    $payload = json_encode([
        'email_address' => $data['email'],
        'status_if_new' => 'subscribed', // Only applied if the member does not exist
        'merge_fields'  => [
            'FNAME' => $data['firstname'] ?? '',
            'LNAME' => $data['lastname'] ?? ''
        ]
    ]);

    // Prepare the request
    $ch = curl_init($url);
    curl_setopt($ch, CURLOPT_USERPWD, "user:{$apiKey}"); // Basic Auth: username is irrelevant
    curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
    curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "PUT");       // The key for upsert behavior
    curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_TIMEOUT, 10);

    // Execute the call
    $response = curl_exec($ch);
    $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $curlErr  = curl_error($ch);
    curl_close($ch);

    // Handle cURL errors
    if ($curlErr) {
        logError("Mailchimp cURL error: $curlErr");
        return;
    }

    // Handle API errors
    if ($httpCode >= 400) {
        logError("Mailchimp API Error ($httpCode): " . $response);
        return;
    }

    // Success (optional: log quietly for debugging)
    logError("Mailchimp: {$data['email']} successfully synced to list {$listId}.", false);
});

Example: CRM integration

PHP
Hooks::addAction(Hook::FORM_AFTER_SUBMISSION, function($submissionId, $data, $formConfig, $files) {

    if ($formConfig['form_handle'] === 'quote_request') {

        // Send data to Salesforce / HubSpot / custom CRM
        MyCrmService::createLead([
            'email'  => $data['email'],
            'name'   => $data['name'],
            'budget' => $data['budget']
        ]);
    }

});

2. Hooks JavaScript (Admin UI)

The form builder exposes a global registry that allows plugins and extensions to render their own configuration interfaces for custom field types.

Entry point : window.CMS.fieldRenderers

Example: Adding a custom UI for a “Rating” field

JAVASCRIPT
// In your plugin JavaScript file
document.addEventListener('DOMContentLoaded', () => {

    window.CMS.fieldRenderers['rating'] = function(fieldId, data) {

        // Return the custom configuration UI HTML
        return `
            <div class="form-container">

                <label>Maximum number of stars</label>

                <input
                    type="number"
                    class="form-control val-max-input"
                    value="${data.validation_rules?.match(/max:(\d+)/)?.[1] || 5}"
                >

                <input
                    type="hidden"
                    name="fields[${fieldId}][validation_rules]"
                    class="validation-rules-final"
                >

            </div>
        `;
    };

});