Addon Development

This guide applies to Horuph Studio v1.0.0.

Contents

Part 1: Structure Help

Horuph Structure (Addon Development)

This page is the starting point for developers who want to build addons for Horuph. The goal is to help you add features (content + console) without changing the core.

Core vs Addons (Two Areas Only)

  • Core: the small base system. As an addon developer, you usually read core docs to reuse helpers and conventions.
  • Addons: everything you build lives under core/addons/my_addon/. Addons are how you add real features (user management, content types, tools, services, etc.).

Important: Horuph core has no hard connections to addons. Core continues to function normally even if all addons are removed. Addons are loaded conditionally - only addons with valid manifest.php files are loaded. This modular design ensures core stability. When addons need to interact with each other, they use conditional checks (e.g., "if addon X exists, do Y") rather than hard dependencies. Horuph itself doesn't manage execution order, but addons can implement their own order management if needed.

Data/Model Discovery (Core vs Addon)

This section explains where tables, schemas, and status values live today, so both developers and AI can find them without guessing.

Core tables

  • Created by the Horuph installer: core/controllers/actions/install.php
  • Migrations live here: core/migrations/<db>/...
  • Current databases: mysql, sqlite

Addon tables

  • Each addon manages its own migrations: core/addons/<addon>/migrations/<db>/...
  • Addon installer (optional extra setup): core/addons/<addon>/controllers/install.php

Enums / status values

Right now, status values are embedded directly in SQL migrations (CHECK/ENUM). Example locations:

  • core/migrations/*/create_table_user.sql
  • core/addons/newsletter/migrations/*/*.sql
  • core/addons/mail_service/migrations/*/*.sql

What is still missing (future improvement)

  • A small schema map page that lists core tables and common fields in plain language.
  • A short list of shared enums/statuses and where they are defined (if any).

Content vs Console (Two Page Types)

  • Content: visitor/user-facing pages. This is what the public site shows.
  • Console: admin/management pages. This is where you manage addon data and settings.

Golden Rules (Keep Addons Simple)

  • Do actions in controllers: create/update/delete/submit should live in controllers.
  • Fetch data in endpoints: lists/details/previews as JSON should live in endpoints.
  • Content vs console stays separate: content is for visitors, console is for management.
  • Prefer addon code over core changes: reuse core helpers; avoid editing core.

Naming & Paths

  • my_addon = addon slug (only lowercase + underscore).
  • Route/file names: action/<name>, endpoint/<name>, window/<name> are typically lowercase + dash (kebab-case). (Example files in core addons: campaigns-list, content-type-save, test-email-send)
  • Examples: endpoint/list-orders, action/save-order, window/order-form
  • Folder meaning: views/ = render only, controllers/ = actions only, others/ = config/installer/bootstrap/etc.

Decision Cheat Sheet

If you are not sure where something belongs, use this:

What you need Use Typical URL Typical file location
A visitor-facing page (HTML) Layout (page) /{urlLocale}/my_addon/... core/addons/my_addon/views/layouts/
Read-only JSON for AJAX (lists/details) Endpoint /{urlLocale}/my_addon/endpoint/<name> core/addons/my_addon/views/endpoints/
Do work (save/delete) and return JSON or redirect Controller action /{urlLocale}/my_addon/action/<name> core/addons/my_addon/controllers/
A popup tool/form on content or console Window /{urlLocale}/my_addon/window/<name> core/addons/my_addon/views/windows/
External integration JSON (/api) API /api/my_addon/<name> core/addons/my_addon/views/api/
Full Horuph context, but no layout (download/export) Shared file /shared/my_addon/<name> core/addons/my_addon/views/shared/
A reusable widget on content pages Block (placed by page builder) core/addons/my_addon/views/blocks/<block>/
An admin page to manage addon data Console /{urlLocale}/addon-console/my_addon core/addons/my_addon/views/console/
Show live stats on the main dashboard Dashboard card (dashboard console) core/addons/my_addon/views/dashboard/
Add tags to page <head> (meta, CSS, etc.) Header file (auto-included in all pages) core/addons/my_addon/header.php
Add scripts before </body> (JavaScript, etc.) Footer file (auto-included in all pages) core/addons/my_addon/footer.php
File accessible from root URL (service-worker.js, manifest.webmanifest, etc.) Root file /{filename} core/addons/my_addon/root/{filename}.php

Endpoint vs API: Endpoint = internal JSON for Horuph pages (runs through normal Horuph request context, use hp_verify_permission()). API = external JSON under /api (minimal bootstrap, no $siteContext; implement token/auth inside your API file).

Request/Response Contract (Recommended)

In your endpoint files, you create a PHP array and assign it to $response. The core system automatically converts this array to JSON when sending the response. Keep your response arrays consistent across your addon.

Recommended array structure:

$response = [
        'success' => true,
        'message' => '',
        'data' => []
    ];

On failure:

$response = [
        'success' => false,
        'message' => 'Invalid input',
        'data' => null,
        'error' => ['code' => 'VALIDATION', 'fields' => ['title' => 'Required']]
    ];

This array will be automatically converted to JSON by the endpoint handler. The JSON output will look like:

{
    "success": true,
    "message": "",
    "data": {}
    }

Array field notes:

  • success: boolean (true for success, false for errors)
  • message: string (human-readable message; empty on success is fine)
  • data: any array value (array/object/null). Put your actual response data here
  • error (optional): array with code (string) and optional fields (array) for validation errors

You can add custom fields to your response array as needed (e.g., page, total, html). They will all be included in the JSON output.

Important: Addons must only use hp_* helpers and documented core APIs. Any direct usage of internal core classes/files is not supported and may break in future updates.

Mini Templates (Copy/Paste)

Endpoint (read-only JSON)

File: core/addons/my_addon/views/endpoints/list-orders.php → URL: /{urlLocale}/my_addon/endpoint/list-orders

<?php
    // Initialize response array
    $response = ['success' => false, 'message' => '', 'data' => []];

    try {
        // Permission check (optional)
        if (!hp_verify_permission('my_addon_orders_view')) {
            $response['message'] = 'Unauthorized';
            $response['error'] = ['code' => 'FORBIDDEN'];
            // Response will be output as JSON by endpoint.php
            return;
        }

        // Get data
        $orders = hp_db_all('SELECT id, total FROM my_orders ORDER BY id DESC LIMIT 50', []);
        
        // Set success response
        $response = [
            'success' => true,
            'data' => $orders
        ];
        
    } catch (Exception $e) {
        $response['success'] = false;
        $response['message'] = 'Error: ' . $e->getMessage();
    }

    // Note: $response array will be automatically converted to JSON by endpoint.php
    ?>

Example with custom fields (pagination):

<?php
    $response = ['success' => false, 'message' => '', 'contents' => [], 'page' => 1, 'total' => 0];

    try {
        $page = intval($_GET['page'] ?? 1);
        $perPage = 20;
        $offset = ($page - 1) * $perPage;
        
        $contents = hp_db_all('SELECT id, title FROM my_items ORDER BY id DESC LIMIT ? OFFSET ?', [$perPage, $offset]);
        $total = hp_db_one('SELECT COUNT(*) as total FROM my_items', [])['total'];
        
        $response = [
            'success' => true,
            'contents' => $contents,
            'page' => $page,
            'total' => intval($total),
            'total_pages' => ceil($total / $perPage)
        ];
    } catch (Exception $e) {
        $response['success'] = false;
        $response['message'] = $e->getMessage();
    }
    ?>

Controller Action (POST → JSON or redirect)

File: core/addons/my_addon/controllers/save-order.php → URL: /{urlLocale}/my_addon/action/save-order

<?php
    $response = ['success' => false, 'message' => '', 'data' => null];

    // $data is JSON-decoded body or $_POST (see action router)
    $title = hp_sanitize($data['title'] ?? '');
    if ($title === '') {
        $response['message'] = 'Invalid input';
        $response['error'] = ['code' => 'VALIDATION', 'fields' => ['title' => 'Required']];
        return;
    }

    $id = hp_db_insert('INSERT INTO my_orders (title) VALUES (?)', [$title]);
    $response = ['success' => true, 'message' => 'Saved', 'data' => ['id' => (int)$id]];

    // Optional redirect (instead of JSON):
    // $response['redirect'] = hp_url($siteContext['urlLocale'], ['my_addon', 'order', (string)$id]);
    ?>

Console View (HTML + load assets)

File: core/addons/my_addon/views/console/orders.php

<div class="p-3">
    <h5 class="mb-3"><?php echo hp_t('orders', 'my_addon'); ?></h5>
    <div id="my_addon_orders_list"></div>
    </div>

    <script>
    // Load data via endpoint (keep actions in controllers)
    // fetch(`/${SITE_DATA.urlLocale}/my_addon/endpoint/list-orders`).then(r => r.json()).then(...)
    </script>

Window (form + submit to action)

File: core/addons/my_addon/views/windows/order-form.php → URL: /{urlLocale}/my_addon/window/order-form

<div class="p-3">
    <h6 class="mb-3"><?php echo hp_t('order_form', 'my_addon'); ?></h6>
    <form method="post" action="/<?php echo $siteContext['urlLocale']; ?>/my_addon/action/save-order">
        <input class="form-control mb-2" name="title" placeholder="Title">
        <button class="btn btn-primary" type="submit">Save</button>
    </form>
    </div>

Fast Path: Build Your First Addon

  1. Create your folder: core/addons/my_addon/.
  2. Add manifest.php and icon.png.
  3. Add language packs for UI text (minimum required: [addon] and [addon]_description keys matching your manifest).
  4. Set up data with migrations (installer optional).
  5. Build management UI with console views (and optional dashboard card).
  6. Build visitor UI with layouts and optional blocks.
  7. Add data routes: endpoints (fetch) + controllers (actions).

Docs Index

Everything is linked from this page. Use the sections below as a map.

Machine Summary

This JSON block is designed for tools/AI to parse. It does not affect page rendering.

Addon Docs (Recommended)

Getting Started

Content (Layouts + Blocks)

Console (Management UI)

JSON Routes (Fetch vs Action)

Special Routes (No Layout)

Page Integration

UI Integration

Design Guidelines

These guides show exact HTML/CSS patterns and best practices for building consistent UI components:

Advanced

Core Docs (Reference)

These pages document core helpers you will use while building addons. You usually do not edit core files; you use these helpers and patterns in your addon.

Client Side

Client-side global helpers:

Bootstrap Context

Database

Date and Time

Translation

URLs and Routing

System Features

System Settings

Users and Permissions

Hooks

Background Jobs

Files and Media

Temporary Storage

Security and Text Cleanup

File and Directory Operations

General Utilities

Part 3: Addon Basics

Developer Mode

Developer mode is a special setting that enables addon development features in Horuph. When enabled, it allows you to work with addons that haven't been installed yet and provides access to development tools.

Default State

Developer mode is disabled by default for security and performance reasons. In production environments, it should remain off.

How to Enable

To enable developer mode, you need to use a special activation method:

  1. Go to Global Settings in the console
  2. Find the "Developer Mode" label
  3. Click on the label 10 times (this is an easter egg to prevent accidental activation)
  4. After 10 clicks, the developer mode toggle will appear
  5. Check the toggle to enable developer mode
  6. Save your changes

Note: The 10-click requirement is intentional to prevent accidental activation. This ensures that only developers who know about this feature will enable it.

What Developer Mode Does

When developer mode is disabled (default):

  • Horuph only recognizes addons that are installed and enabled in the database
  • Addons are loaded from the system_addons database table
  • Only addons with is_enabled = 1 are available
  • This is the recommended setting for production environments

When developer mode is enabled:

  • Horuph recognizes all addons that exist in the core/addons/ directory, even if they're not installed
  • Addons are discovered by scanning the file system using glob()
  • You can develop and test addons without installing them first
  • Access to the Addon Wizard tool for creating new addons (visible in the addons console)
  • Custom input types from addons are automatically loaded (even if addon is not installed)
  • Addon JavaScript and CSS files are automatically included in bundles

Development Tools

When developer mode is enabled, you get access to:

  • Addon Wizard - A tool to create new addons with a guided interface (visible in /addon-console/install/addons)
  • File System Addon Discovery - All addons in core/addons/ are automatically detected
  • Hot Development - Test addon changes without installation steps

Configuration

Developer mode is stored in the configuration file:

horuph.php
    'dev_mode' => 0,  // 0 = disabled, 1 = enabled

You can also set it in horuph.php directly, but using the Global Settings interface is recommended.

Important Notes

  • Security: Keep developer mode disabled in production environments
  • Performance: File system scanning is slower than database queries, so developer mode may slightly impact performance
  • Testing: Always test your addons with developer mode disabled before deploying to production
  • Installation: Addons created in developer mode still need to be properly installed for production use

Where It's Used

Developer mode affects several parts of the system:

  • Addon Loading - How addons are discovered and loaded (core/bootstrap.php)
  • Input Fields - Custom input types from addons (core/views/input_fields.php)
  • JavaScript Bundles - Addon JS files in assets/js/ (core/views/statics/js.php)
  • CSS Bundles - Addon CSS files in assets/css/ (core/views/statics/css.php)
  • Addon Wizard - Visibility of addon creation tool

Addon Manifest

Every addon ships with a manifest.php file that declares the addon's identity, version, dependencies, and toolbar visibility. The manifest lives entirely inside the addon folder under core/addons/, so you never touch core files when updating addon metadata.

Folder Layout

core/
    addons/
        my_addon/
        manifest.php
        views/
        support/
        ...

Manifest Structure

The manifest returns a PHP array. Keep keys in lowercase snake case, and remember version numbers always follow MAJOR.MINOR.PATCH.

  • title: Machine-friendly addon name (used for folder name and translations).
  • description: Translation key that describes the addon in listings.
  • version: Current addon version in MAJOR.MINOR.PATCH format.
  • db_version (optional): Integer used by migration scripts to track schema updates.
  • requires_core: Semver constraint for the minimum compatible core version (example: ">=1.0.0").
  • requires_addons (optional): Addon requirements. You can define this as an associative array (addon => version) or as a list of arrays with addon and version keys.
  • toolbar (optional): Declares the toolbar icon placement in Horuph CMS. You can set global, local, or bar to true depending on where the icon should appear (you can mix and match as needed).
  • preserve_on_update (optional): List of addon files that should not be overwritten during update (relative to the addon folder).

Example Manifest

<?php
    return [
        'title' => 'my_addon',
        'description' => 'my_addon_description',
        'version' => '1.2.0',           // MAJOR.MINOR.PATCH
        'db_version' => 3,                // optional schema tracker
        'requires_core' => '>=1.1.0',
        'requires_addons' => [
            'mail_service' => '>=1.0.0',
            'qrcode' => '>=1.0.0',
        ],
        // Alternative format:
        // 'requires_addons' => [
        //     ['addon' => 'my_dependency_addon', 'version' => '>=1.0.0']
        // ],
        'toolbar' => [
            'global' => true,             // show icon in the global toolbar
            'local' => false,             // hide from local toolbar
            'bar' => true                 // show inline icon inside the bar view
        ],
        'preserve_on_update' => [
            'environment.php',
            'environment_users.php',
        ]
    ];
    

How to Create

  1. Create core/addons/my_addon/manifest.php (or edit the existing file).
  2. Return an array with at least title, description, version, and requires_core. Keep version in MAJOR.MINOR.PATCH format.
  3. Add db_version when your addon runs migrations and needs schema tracking.
  4. List dependencies inside requires_addons when you rely on another addon. You can use addon => version pairs or the array-of-arrays format.
  5. Declare toolbar visibility using the toolbar array. Set global, local, or bar to true when you want icons in those sections.
  6. Use preserve_on_update if you ship files that should survive addon updates (relative paths from the addon root).

Tips

  • Always bump the manifest version whenever you add features, fix bugs, or change the database schema.
  • Keep translation keys (for title and description) consistent with the language files inside your addon. Your language packs must include [addon] (matching title) and [addon]_description (matching description) as minimum required keys. See Language Packs for details.
  • When an addon has no dependencies, set requires_addons to an empty array to make the intent explicit.
  • Only include the toolbar flags your addon actually uses and follow the toolbar icon guide when you expose buttons.

More on Toolbar Icons

After setting the toolbar flags, follow the Addon Toolbar Icons guide to display icons in the tools dropdown or directly on the bar.

Addon Installer

Addons do not need an installer file for database setup. Horuph automatically runs migration SQL files during install/update.

Optional: Create controllers/install.php only if you need extra, non-routine work (seed data, generate keys, create folders, validate environment). Do not repeat migrations in this file.

Prerequisites: The addon must have a valid manifest.php file so Horuph can recognize the addon during install/update.

Folder Layout

core/
    addons/
        my_addon/
        controllers/
            install.php (optional)
        migrations/
            mysql/
            1/
                create_table_example.sql
            sqlite/
            1/
                create_table_example.sql

Optional Installer Controller

  1. Create controllers/install.php only if you need custom setup logic.
  2. Keep it idempotent: it can run during install or update.
  3. Do not run migrations here; Horuph runs them automatically.
  4. Set a response with a success message (redirect is optional).

Optional Installer Template (Extra Setup Only)

<?php
    if (!hp_verify_permission('install_addons')) {
        = [
            'success' => false,
            'message' => 'You are not authorized to install this addon'
        ];
        ['ui_alert'] = 'error';
        return;
    }

    try {
        // Extra setup only (examples):
        // - seed default rows
        // - generate keys
        // - create storage folders

        = [
            'success' => true,
            'message' => 'Addon setup completed'
        ];
        ['ui_alert'] = 'success';
    } catch (Exception ) {
        hp_log('My Addon Install Error', ['error' => ->getMessage()], 'ERROR');
        = [
            'success' => false,
            'message' => 'Setup failed: ' . ->getMessage()
        ];
        ['ui_alert'] = 'error';
    }
    

Create Migration Files

For each table you need, create two SQL files: one for MySQL and one for SQLite. Both should have the same name and be placed in separate folders. Horuph runs them automatically during install/update.

Migration File Structure

Place migration files in migrations/mysql/1/ and migrations/sqlite/1/. Use the naming pattern create_table_<table_name>.sql. Add new version folders (2, 3, ...) when you introduce new schema changes.

MySQL Migration Example

-- Create my_table for MySQL
    CREATE TABLE IF NOT EXISTS `my_table` (
        `id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
        `name` VARCHAR(255) NOT NULL,
        `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
        PRIMARY KEY (`id`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

SQLite Migration Example

-- Create my_table for SQLite
    CREATE TABLE IF NOT EXISTS my_table (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT NOT NULL,
        created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
    );

Database Type Differences

  • Auto Increment: MySQL uses AUTO_INCREMENT, SQLite uses AUTOINCREMENT.
  • Integer Primary Key: MySQL uses INT(11) UNSIGNED AUTO_INCREMENT, SQLite uses INTEGER PRIMARY KEY AUTOINCREMENT.
  • Text Fields: MySQL uses VARCHAR(n) or TEXT, SQLite uses TEXT.
  • DateTime: MySQL uses DATETIME, SQLite uses TEXT.
  • Boolean: MySQL uses TINYINT(1), SQLite uses INTEGER.
  • Indexes: MySQL can define indexes inside CREATE TABLE, SQLite usually needs separate CREATE INDEX statements.

Multiple Tables

If your addon needs multiple tables, add multiple SQL files in the same version folder:

migrations/
    mysql/1/
        create_table_table_one.sql
        create_table_table_two.sql
    sqlite/1/
        create_table_table_one.sql
        create_table_table_two.sql

Tips

  • Use CREATE TABLE IF NOT EXISTS in your SQL files for extra safety.
  • Keep MySQL and SQLite versions of each migration file in sync.
  • Test your migrations with both MySQL and SQLite databases.
  • Name your migration files clearly to match the table name they create.
  • Do not duplicate migrations inside controllers/install.php.

Update / Migration Workflow

Horuph runs migrations automatically on both install and update. The rules are deterministic:

  1. Place SQL files inside version folders: migrations/<db>/1/, migrations/<db>/2/, ...
  2. Version folders are executed in numeric order (1, 2, 3...).
  3. Within each version folder, SQL files are executed alphabetically.
  4. Set db_version in manifest.php to the latest migration folder number.

Safe re-run: migrations must be idempotent because Horuph may re-run them during updates. Use IF NOT EXISTS (tables, indexes), and prefer additive changes. If a migration fails mid-upgrade, fix the SQL and re-run the install/update; idempotent migrations make this safe.

Data changes: if you need data transforms or non-SQL actions, use the optional controllers/install.php and keep it idempotent.

Uninstall / Cleanup Flow

Uninstall is handled by core and is consistent for all addons:

  1. Creates a backup ZIP of the addon folder.
  2. Creates SQL backups based on migration files.
  3. Runs optional controllers/uninstall.php (if present).
  4. Drops addon tables (based on create_table_*.sql file names).
  5. Removes addon records from system_addons and system_addons_deps.
  6. Deletes the addon folder.

Optional uninstall hook: use controllers/uninstall.php only for extra cleanup (external files, remote resources, non-database data). If it returns success: false, uninstall stops.

Dependencies: core does not block uninstall for dependent addons. If your addon depends on another addon, check and warn the user before uninstall, or handle it inside your uninstall hook.

Packaging & Distribution

Addons are distributed as ZIP files that contain the addon folder contents. The ZIP root must include manifest.php.

Required Files

  • manifest.php (required)
  • languages/<locale>.json with [addon] and [addon]_description keys
  • migrations/<db>/<version>/*.sql if the addon uses a database
  • icon.png (recommended for console UI)

Versioning

  • Use semantic versioning: major.minor.patch.
  • Update manifest.php version on any release.
  • Update db_version when you add a new migration folder.

Packaging Command (Terminal Addon)

Use the terminal command to create a ZIP:

pack:addon <addon_name>
    pack:addon <addon_name> --download

This command creates a ZIP in storage/temp. The install flow extracts it into core/addons/<addon>.

Recommended Release Structure

  • Keep addon root clean: manifest.php, icon.png, languages/, migrations/, views/, controllers/, support/.
  • Use preserve_on_update in manifest.php for files that must survive updates (for example, environment.php).
  • Do not bundle runtime data (logs, cache, user uploads) inside the addon ZIP.

Addon Language Packs

Every addon can ship its own translations. You only need a languages folder with one JSON file per locale. Follow the steps below and you will have a working language pack in minutes.

Folder Layout

core/
    addons/
        my_addon/
        languages/
            en_us.json
            fa_ir.json
            ...

Create or Update a Locale File

  1. Pick the locale code. Use lowercase language + underscore + lowercase country (example: en_us, fa_ir).
  2. Create languages/<locale>.json if it does not exist yet.
  3. Add your keys and texts inside a single JSON object. Keys should be short snake_case names. Texts are plain UTF-8 strings.

Required Keys

Every language pack must include at least these two keys that match your manifest:

  • [addon] - The addon title (matches manifest title field)
  • [addon]_description - The addon description (matches manifest description field)

For example, if your manifest has 'title' => 'my_addon' and 'description' => 'my_addon_description', your JSON must include:

{
        "my_addon": "My Addon",
        "my_addon_description": "Description of my addon"
    }

All other translation keys are optional and can be added as needed.

Example with Optional Keys

{
        "my_addon": "My Addon",
        "my_addon_description": "Description of my addon",
        "welcome_title": "Welcome to the addon",
        "button_save": "Save changes"
    }

Use the Strings

Inside PHP templates or controllers call hp_t('key_name', 'my_addon') to print the text for the current locale. Example:

<h1><?php echo hp_t('welcome_title', 'my_addon'); ?></h1>
    <button><?php echo hp_t('button_save', 'my_addon'); ?></button>

If a key is missing you will see -my_addon-key_name on screen. That means the JSON file needs that entry.

Force a Specific Locale

Need content from a different language than the current page? Use hp_t_locale($locale, 'key_name', 'my_addon'). Pass the locale code (like 'fa_ir') and you will always get that language, even inside a different locale route.

Translations in JavaScript

The global trans(key, addon) function defined in public/assets/js/global.js mirrors hp_t(). It returns a promise, so you can await it or use .then():

const title = await trans('welcome_title', 'my_addon');
    document.querySelector('#title').textContent = title;

Omit the addon to read from the main dictionary, pass 'custom' for the custom dictionary, or provide your addon slug.

Tips

  • Keep at least one full locale (usually en_us.json) so other locales can copy from it.
  • JSON must stay valid: wrap strings in double quotes, separate entries with commas, and do not add comments.
  • Reload the page after editing to force the dictionary cache to refresh.
  • For locale-specific public pages use hp_t_locale($locale, 'key', 'my_addon') to fetch text from a different language file.

Addon Assets

Store every static file for your addon under core/addons/<addon_name>/assets. Create whatever folders you need (images, fonts, data, etc.) and reference them directly; no build step is required. Two folders have special powers: assets/js and assets/css.

Prepare the Folders

  1. Create assets/js and/or assets/css inside your addon.
  2. Drop one of the reserved filenames listed below into those folders.
  3. Reload the site. The platform automatically includes your file in the correct bundle.

JavaScript Files

File (inside assets/js/) Where it loads Use it for
global.js Every page (public + console) Universal helpers or UI widgets that all views need.
content.js All public/content pages (no console) Landing-page interactions, forms, or effects for visitors.
console.js Every console/admin page Dashboard modules, admin shortcuts, or list enhancements.

Example: to run code on all console pages, create core/addons/<addon_name>/assets/js/console.js and paste your script there. After a refresh, the console automatically executes it.

CSS Files

File (inside assets/css/) Where it loads Use it for
global.css Every page Base variables, utility classes, or shared components.
content.css Public/content pages only Marketing layouts, block styling, or anything meant for visitors.
console.css Console/admin pages Dashboard cards, grid tweaks, or admin-specific color schemes.

Only create the files you need. For example, if the addon never touches the console, skip console.css.

Other Asset Types

  • images/ – store PNG/JPG/SVG files and reference them with /core/addons/<addon>/assets/images/<file>.
  • fonts/ – add custom webfonts and load them from your CSS with @font-face.
  • data/ – ship JSON or text files that your addon reads at runtime.
  • You can add any other folders you want; only the reserved CSS/JS filenames trigger automatic loading.

Manual Asset Loading

For packages that include external CSS/JS files, you have additional options:

  • Direct inclusion in views: You can manually include CSS files in the page header and JavaScript files at the end of the page in your view files.
  • System template injection: For global CSS/JS that should load on all pages (CSS in header, JS below system scripts in footer), you can use the system's local settings to inject code into header.phtml and footer.phtml templates. Access these through Console → Settings → Header Tags and Console → Settings → Footer Tags.

Important: The three predefined bundles (global, content, console) are automatically handled by Horuph. Manual inclusion is only needed for external packages or when you need specific load order control.

Tips

  • Namespace classes and IDs (e.g., .addon-<addon_name>-card) so your styles do not clash with others.
  • Use relative paths for images referenced from CSS (the platform keeps them intact) or absolute URLs if you prefer.
  • For page-specific logic, still place the code in the bundle but guard execution with simple checks (e.g., look for a data attribute) so unused pages exit early.
  • Prefer the predefined bundles (global.js, content.js, console.js) when possible, as they are automatically loaded by Horuph.
  • Use manual inclusion or template injection only when you need external packages or specific load order requirements.

Addon Icon

Every addon should include an icon.png file. This icon is used across Horuph UI (toolbar, tools dropdown, dashboard cards, and console pages), so keeping it consistent prevents broken layouts and unreadable icons.

Folder Layout

core/
    addons/
        my_addon/
        icon.png

Icon Requirements

  • Format: PNG
  • Size: 400x400 pixels
  • Background: Transparent
  • Design: Single-color white glyph/logo (recommended)

Why Transparent + White Works Best

Horuph can display icons on different backgrounds and may apply color or styling changes depending on where the icon is rendered. A transparent background with a single white icon:

  • Keeps the icon readable on dark toolbars and light panels.
  • Prevents ugly boxes around icons in lists and buttons.
  • Makes it safer when the UI applies filters or tinting.

Examples

  • core/addons/[addon]/icon.png

Tips

  • Keep the main glyph centered with padding so it does not touch the edges.
  • Avoid thin strokes that disappear at small sizes (toolbar icons are small).
  • Do not include text inside the icon; it becomes unreadable when scaled down.

Part 4: Addon Views

Addon Pages

This guide shows how to add pages inside an addon. In Horuph, addon pages are rendered by views/layouts/default.php, and each URL must have a config (otherwise the site returns 404).

Folder Layout

core/
    addons/
        my_addon/
        views/
            layouts/
            _config.php
            default.php
            home.php
            item.php

How Routing Works

Addon pages use default routing where URLs are built from urlData arrays. The default address for an addon layout is:

/{urlLocale}/my_addon

Extra URL parts are available in $siteContext['urlData']. For example:

/{urlLocale}/my_addon/item/123

In that case, $siteContext['urlData'] is usually:

["my_addon", "item", "123"]

Note: URL mapping and redirects are handled by the urls addon. See URLs Addon Dev Help for current mapping and redirect behavior.

Step 1: Create the Page Config

Create core/addons/my_addon/views/layouts/_config.php. This file must return a PHP array for every URL you want to support. If it returns an empty value, the page is considered missing and the site returns 404.

Minimal Config Example

<?php
    $pageData = [];

    // /{urlLocale}/my_addon
    if (count($siteContext['urlData']) === 1) {
        $pageData = [
            'type' => 'page', // "page" or "section"
            'title' => 'My Addon',
            'description' => 'Welcome to my addon.',
            'parents' => [],
            'url_path' => 'my_addon',
        ];
    }

    // /{urlLocale}/my_addon/item/123
    if (count($siteContext['urlData']) === 3 && $siteContext['urlData'][1] === 'item') {
        $itemId = (int)($siteContext['urlData'][2] ?? 0);
        if ($itemId > 0) {
            $pageData = [
                'type' => 'page',
                'title' => 'Item #' . $itemId,
                'description' => 'Item details page.',
                'parents' => [['my_addon']],
                'url_path' => 'my_addon/item/' . $itemId,
            ];
        }
    }

    return $pageData;

Common Config Fields

  • type: Use page or section.
  • title, description: Used for the page meta tags.
  • parents: A list of parent URL data arrays (for blocks/breadcrumb-like logic).
  • url_path: The real path (without locale) used for URL mapping and redirects.
  • pagepriority, pagechangefreq: Optional SEO fields.

Step 2: Render the Page

Create core/addons/my_addon/views/layouts/default.php. This file decides which layout file to include based on $siteContext['urlData'].

Default Layout Example

<?php
    // /{urlLocale}/my_addon
    if (count($siteContext['urlData']) === 1) {
        include BASE_PATH . '/core/addons/my_addon/views/layouts/home.php';
        return;
    }

    // /{urlLocale}/my_addon/item/123
    if (count($siteContext['urlData']) === 3 && $siteContext['urlData'][1] === 'item') {
        include BASE_PATH . '/core/addons/my_addon/views/layouts/item.php';
        return;
    }

URL Mapping (Optional)

URL mapping and redirects live in the urls addon now. See URLs Addon Dev Help for how to add inputs, save mappings, and use redirects.

Tips

  • Keep _config.php fast: only set what you need (title, description, parents, url_path).
  • For unknown URLs, return an empty value from _config.php to keep the 404 behavior.
  • Try to keep default.php simple: just route and include layout files.
  • Always use hp_url($locale, $urlData) to generate links - it automatically handles both default routing and URL mapping.
  • Always use $siteContext['urlData'] to understand the current page, not $siteContext['url_path'] (which may be mapped).

Site Blocks (Addon)

Site pages can show blocks above, below, or around the main page content (wherever the page template allows). A site designer can decide which blocks appear, where they appear, and when they appear.

A block can be anything: a tool (converter), a small form (signup), a list (latest news), a menu, a carousel, and more.

Block Types

  • Dynamic blocks: live in code (core or addons) and render with default.php.
  • Static blocks: live in public/languages/{locale}/blocks/ and are saved as editable HTML (default.phtml).

This guide is only about addon dynamic blocks.

Folder Layout

core/
    addons/
        my_addon/
        views/
            blocks/
            latest-news/
                _config.php
                default.php
                settings.php (optional)

How to Create

  1. Create core/addons/my_addon/views/blocks/{block-name}/.
  2. Create _config.php to describe the block in the UI (title + description).
  3. Create default.php to render the block on the site.
  4. Optionally create settings.php if the block needs admin settings (opened as a WindowBox window).

Create _config.php

This file returns a PHP array. Use it to provide a title and description (usually translation keys).

<?php
    return [
        'title' => 'latest_news',
        'description' => 'latest_news_description',
    ];

Tip: some parts of the UI may also look for name. If you want maximum compatibility, you can set both:

<?php
    return [
        'title' => 'latest_news',
        'name' => 'latest_news',
        'description' => 'latest_news_description',
    ];

Create default.php

This file renders the block HTML on the site. Keep it lightweight and safe (escape output when needed). You can use Bootstrap classes like forms, alerts, tables, and buttons.

// core/addons/my_addon/views/blocks/latest-news/default.php
    <div class="p-3">
        <h6 class="mb-3"><?php echo hp_t('latest_news', 'my_addon'); ?></h6>
        <ul class="mb-0">
            <li><a href="#">Item 1</a></li>
            <li><a href="#">Item 2</a></li>
        </ul>
    </div>

Optional: Create settings.php (Admin Window)

If your block needs settings (example: number of items, layout mode, show images), create settings.php. The site builder opens this file inside a WindowBox window.

Because settings open in a window, output only the window body HTML (no <html>, <head>, or layout wrapper). When you submit a settings form, include a return URL so the user is redirected back to the page builder.

Common variables available inside settings.php include:

  • $blockId: the block row ID from system_blocks (use it as your settings key).
  • $scope, $holder: where the block is placed.
  • $urlData, $parentData: extra placement context (arrays).
  • $return: the return URL passed into the window (preferred for redirects).
  • $siteContext: common site context values (fallback return: $siteContext['url']).

Minimal Settings Form Example

// core/addons/my_addon/views/blocks/latest-news/settings.php
    <div class="p-3">
        <h6 class="mb-3"><?php echo hp_t('block_settings', 'my_addon'); ?></h6>

        <form action="/<?php echo $siteContext['urlLocale']; ?>/my_addon/action/block-settings-save" method="POST">
            <input type="hidden" name="return" value="<?php echo htmlspecialchars($return ?: ($siteContext['url'] ?? '/'), ENT_QUOTES, 'UTF-8'); ?>">
            <input type="hidden" name="block_id" value="<?php echo htmlspecialchars($blockId, ENT_QUOTES, 'UTF-8'); ?>">

            <label class="form-label">Count</label>
            <input class="form-control mb-3" type="number" min="1" max="50" name="count" value="5">

            <button class="btn btn-primary" type="submit">Save</button>
        </form>
    </div>

In your action controller (my_addon/action/block-settings-save), save settings using block_id (for example, in your own table), then redirect to return.

Tips

  • Keep block output fast: avoid heavy queries on every page load (cache when possible).
  • Use unique IDs and JS function names so multiple blocks/windows do not conflict.
  • If you build a tool or form inside a block, validate inputs and escape output like any other page.
  • For settings windows, use WindowBox behavior described in WindowBox.

Addon Consoles

Addon consoles are multi-tab admin pages that live inside an addon and let administrators manage that addon's data. Everything happens under core/addons/, so nothing in this guide touches core code.

Folder Layout

core/
    addons/
        my_addon/
        views/
            console/
            _config.php
            _permissions.php (optional)
            dashboard.php (sample name)
            reports.php (sample name)
            settings.php (sample name)

How Consoles Render

core/views/layouts/partials/addon_console.php bootstraps every console. It loads _config.php, builds the desktop tab labels and mobile icons, checks permissions, and then includes every visible tab file. Because every tab file runs during the same request, keep function names, variables, and scripts unique to avoid collisions.

Create _config.php

This file returns an array that describes the console. Use it to define the console title, tab order, visibility, icons, and any scripts you want injected after the tabs load.

<?php
    return [
        'title' => 'my_addon',
        'tabs' => [
            [
                'file' => 'dashboard',
                'name' => 'Dashboard',
                'icon' => 'bi bi-speedometer',
            ],
            [
                'file' => 'reports',
                'name' => 'Reports',
                'icon' => 'bi bi-graph-up',
                'hidden' => 0, // use 1 to hide the tab until you need it
            ],
            [
                'file' => 'settings',
                'name' => 'Settings',
                'icon' => 'bi bi-sliders',
                'hidden' => ($siteContext['tab'] === 2 ? 0 : 1), // reveal dynamically
            ],
        ],
        'scripts' => [
            ['file' => '/public/assets/addons/my_addon/console.js'],
        ],
    ];
    

Rules of thumb:

  • Each tab definition needs file, name, and icon. The name shows on desktop; the icon shows on mobile.
  • Use the optional hidden flag (truthy value) to hide tabs until a condition is met. You can use hp_verify_permission() to control tab visibility based on user permissions.
  • Place any script URLs in the scripts array so they load automatically after the console markup.

Sharing Data Between Console Tabs

Since all console tabs load in the same page request, you can share data between them:

  • Using $siteContext: Data added to $siteContext in bootstrap or one tab is accessible in all other tabs.
  • Using PHP variables: Variables defined in one tab are available in subsequent tabs (be careful with variable name conflicts).
  • Using JavaScript: Scripts and functions defined in one tab can be called from other tabs (use namespaces to avoid conflicts).
<?php
    // In dashboard.php tab
    $siteContext['my_addon']['dashboard_data'] = ['total_items' => 100];

    // In reports.php tab (loaded after dashboard.php)
    $totalItems = $siteContext['my_addon']['dashboard_data']['total_items'] ?? 0;
    ?>

Example: Controlling Tab Visibility with Permissions

Use hp_verify_permission() in the hidden flag to hide tabs from users who don't have the required permission:

<?php
    return [
        'title' => 'my_addon',
        'tabs' => [
            [
                'file' => 'dashboard',
                'name' => 'Dashboard',
                'icon' => 'bi bi-speedometer',
            ],
            [
                'file' => 'roles',
                'name' => 'Roles',
                'icon' => 'bi bi-person-lock',
                'hidden' => (!hp_verify_permission('my_addon_roles_view') ? 1 : 0),
            ],
            [
                'file' => 'settings',
                'name' => 'Settings',
                'icon' => 'bi bi-gear',
                'hidden' => (!hp_verify_permission('my_addon_settings_update') ? 1 : 0),
            ],
        ],
    ];
    ?>

If a user lacks the required permission, the tab will be hidden from the console interface. Remember to also check permissions inside the tab file itself for additional security.

Create Tab Files

Each tab needs one PHP file inside the same console folder, and the filename (without .php) must match the tab's file value. You control everything inside these files—render tables, forms, toolbars, workers, or AJAX endpoints as needed.

Important: All console tabs are loaded in the same page request, so:

  • All tabs share the same PHP global scope - use unique variable names, function names, and IDs to avoid conflicts.
  • All JavaScript in all tabs runs in the same page context - scripts defined in one tab are accessible from other tabs. Use namespaces or scoped functions to avoid collisions.
  • All tabs have access to the same $siteContext data - you can share data between tabs through $siteContext (for example, set in bootstrap or one tab, use in another).
  • Gate risky features with permission checks - all tabs share the same global scope for that request.
  • Load translations with hp_t('key', 'my_addon') so labels stay localizable.

Permissions and Access Control

Horuph provides a standard permission system through hp_verify_permission(), but permission management (roles, user assignments) is handled by addons. Here's how it works:

  • Owner user: The owner user automatically has all permissions. Horuph core checks this first before delegating to any permission manager.
  • Permission manager addon: If a permission manager addon is configured (via $siteContext['permission_manager']), Horuph delegates permission checks to core/addons/<addon>/support/verify_permission.php. This allows addons like a users addon to manage roles and permissions.
  • Standard interface: Always use hp_verify_permission('permission_key') in your code. The core handles the delegation automatically.

Optional _permissions.php

If your console needs fine-grained access control, add _permissions.php. The file returns an array of permission keys that administrators can attach to roles. Without it, any admin user who can reach the console gets every feature.

For detailed information on creating permission files, see Addon Console Permissions.

<?php
    return [
        'my_addon_console_access',
        'my_addon_reports_view',
        'my_addon_reports_export',
        'my_addon_settings_edit',
    ];
    

Use these keys with hp_verify_permission() in your tab files to control access:

<?php
    // Inside a tab file (e.g., reports.php)
    if (!hp_verify_permission('my_addon_reports_view')) {
        echo '<p class="text-muted">You do not have permission to view this tab.</p>';
        return; // Exit early
    }
    // ... rest of tab content
    ?>

Note: If permission check fails, the action should not execute. Always check permissions before displaying sensitive content or performing actions.

Permission Key Naming

Follow a consistent naming pattern for permission keys. Recommended pattern: addon_name_action_name (lowercase with underscores). Examples:

  • my_addon_console_access - General access to the addon console
  • my_addon_reports_view - View reports tab
  • my_addon_reports_export - Export reports functionality
  • my_addon_settings_edit - Edit settings
  • my_addon_items_create - Create new items
  • my_addon_items_delete - Delete items

Real-world examples from existing addons:

  • contents_view, contents_create, contents_edit, contents_delete - Used in Contents addon
  • cloudflare_requests_view, cloudflare_requests_delete - Used in Cloudflare addon
  • users_view, users_create, users_edit, users_delete - Used in Users addon
  • users_roles_view, users_roles_create, users_roles_edit, users_roles_delete - Used in Users addon for role management

Important: Use the same permission keys consistently across console tabs, controllers, endpoints, and windows. This ensures unified access control. For example, if you define my_addon_items_delete in _permissions.php, use the same key when checking permissions in:

  • Console tab files (to show/hide delete buttons)
  • Controller actions (to allow/deny delete operations)
  • Endpoints (to gate delete-related data)
  • Windows (to control access to delete confirmation dialogs)

See Addon Console Permissions for complete examples showing how the same permission keys are used across all contexts. Also see hp_verify_permission for usage in controllers and endpoints.

Console UI Guidelines

Follow the shared UI patterns while building each tab:

Example Workflow

  1. Create core/addons/my_addon/views/console/ (if it does not exist).
  2. Add _config.php with your tab definitions, icons, and scripts.
  3. Create one PHP file per tab (for example, dashboard.php, reports.php, settings.php).
  4. Optionally add _permissions.php and use those keys in your tab files before sensitive actions.
  5. Load the addon console from the admin UI to verify every tab renders and scripts execute correctly.

Tips

  • Keep tab names short so they fit on small screens; rely on icons for the mobile navigation strip.
  • Hide unfinished tabs with hidden => 1 so you can ship incrementally.
  • Share helper methods through your addon helpers instead of redefining them inside console tabs.
  • Keep JavaScript modular and scoped to the tab container to avoid conflicts when every tab's code loads together.

Addon Dashboard Cards

Addon dashboard cards are the small status blocks that appear in the first row of the main dashboard console. They surface quick stats and shortcuts for each addon without requiring developers to open that addon's console. Everything lives under core/addons/, so you only touch addon files - core already provides the carousel, borders, and title row.

Folder Layout

core/
    addons/
        my_addon/
        views/
            dashboard/
            default.php        <!-- required: renders the card body -->
            data.php           <!-- optional: feeds live updates -->
            assets/
                dashboard.js     <!-- optional JS/CSS you load from default.php -->

Create the dashboard/ folder once per addon. You can add any helper classes beside these files, but the platform only looks for default.php and (if present) data.php.

How the Core Loads Your Card

The dashboard carousel in core/views/console/dashboard/dashboard.php does the heavy lifting:

  • It gathers disabled cards from hp_get_setting('disabled_dashboard_cards') and skips addons that appear on that list.
  • Each addon card is gated by hp_verify_permission($addon['title'] . '_console_access'), so only users who can open the addon console see the card.
  • The carousel automatically wraps your output inside <div class="cards-carousel-item"> and <div class="dashboard-card d-flex flex-column h-100">, renders the addon icon, and prints the translated addon name as the first row.
  • Your default.php file is included inside that wrapper (row 2 for stats and row 3 for links). Never echo another outer card container or the icon/title row.

Live data comes from core/views/endpoints/dashboard-data.php, which includes every data.php it finds and merges the arrays. The dashboard JavaScript (see core/views/console/dashboard/dashboard.php:1395-1464) refetches this endpoint every 15 seconds and updates any elements whose id matches the keys you return.

Create default.php

This file renders the visible part of your card. Follow the shared structure so the carousel stays consistent:

  1. Gather any counts or timestamps you want to show (wrap database access in try/catch so errors do not break the dashboard).
  2. Print your stat rows inside a parent <div>, using stat-item, stat-label, and stat-value classes for each line.
  3. Add the footer links inside a <div class="mt-auto"> so the action buttons stay pinned to the bottom.
<?php
    // Example metrics (keep this lightweight)
    $pending = 0;
    $sentToday = 0;

    try {
        if (hp_table_exists('my_table')) {
            $pendingRow = hp_db_one(\"SELECT COUNT(*) AS count FROM my_table WHERE status = 'pending'\", []);
            $pending = (int)($pendingRow['count'] ?? 0);

            // NOTE: adapt this query to your DB and schema
            $sentRow = hp_db_one(\"SELECT COUNT(*) AS count FROM my_table WHERE DATE(sent_at) = ?\", [date('Y-m-d')]);
            $sentToday = (int)($sentRow['count'] ?? 0);
        }
    } catch (Exception $e) {
        hp_log('dashboard card error', ['error' => $e->getMessage()], 'ERROR');
    }
    ?>

    <!-- row 2: stats (middle) -->
    <div>
        <div class=\"stat-item\">
            <span class=\"stat-label\"><?php echo hp_t('pending', 'my_addon'); ?>:</span>
            <span class=\"stat-value\" id=\"my_addon_pending\"><?php echo $pending; ?></span>
        </div>
        <div class=\"stat-item\">
            <span class=\"stat-label\"><?php echo hp_t('today', 'my_addon'); ?>:</span>
            <span class=\"stat-value\">
                <span id=\"my_addon_sent_today\"><?php echo $sentToday; ?></span>
            </span>
        </div>
    </div>

    <!-- row 3: link (always bottom) -->
    <div class=\"mt-auto\">
        <a
            href=\"<?php echo hp_url($siteContext['urlLocale'], ['addon-console', 'my_addon']); ?>\"
            class=\"text-decoration-none small text-primary\">
            <i class=\"bi bi-caret-<?php echo $siteContext['alignmentRev']; ?>-fill\"></i>
            <?php echo hp_t('open_console', 'my_addon'); ?>
        </a>
    </div>
    

Tips for default.php:

  • Assign ids to every value you need to update dynamically.
  • Use relative-time plus data-datetime for timestamps, and wrap optional blocks in a container that can toggle d-none.
  • Localize all labels with hp_t('key', 'my_addon') so the dashboard stays translated.
  • Call hp_url() for actions; never hardcode paths.

Optional data.php for Live Updates

If your card should refresh without reloading the page, add a sibling data.php. It returns an array with up to three keys:

  • texts: associative array mapping element IDs to new text values.
  • texts_data: array of ['id' => ..., 'data' => 'attribute', 'value' => '...'] entries to update attributes like data-datetime.
  • classes: array of ['id' => ..., 'class' => '...'] entries to replace class lists (handy for showing/hiding wrappers or toggling status colors).
<?php
    $pending = getPendingCount();
    $failed = getFailedCount();
    $lastRun = getLastRunAt(); // e.g. \"2025-12-23 12:34:56\"

    return [
        'texts' => [
            'my_addon_pending' => (string)$pending,
            'my_addon_failed' => (string)$failed
        ],
        'texts_data' => [
            [
                'id' => 'my_addon_last_run',
                'data' => 'data-datetime',
                'value' => htmlspecialchars($lastRun, ENT_QUOTES, 'UTF-8')
            ]
        ],
        'classes' => [
            [
                'id' => 'my_addon_failed_wrapper',
                'class' => ($failed > 0 ? '' : 'd-none')
            ]
        ]
    ];
    

Keep data.php lightweight: it runs every ~15 seconds for every addon, so use indexed queries and skip work when the addon has no data tables yet.

Visibility and Permissions

  • Cards automatically hide when disabled_dashboard_cards contains addon/my_addon/default. Respect that setting by keeping your IDs stable.
  • Users must have the my_addon_console_access permission (defined in your addon permissions file) to see the card. Use the same key when you need to check permissions inside default.php.
  • Queue worker cards are considered core; everything else is an addon card. Do not attempt to inject other wrappers or rename the default view.

Workflow Checklist

  1. Create core/addons/my_addon/views/dashboard/ if it does not exist.
  2. Build default.php with your stat rows, translated labels, and mt-auto footer links.
  3. Add data.php when you need live updates, returning the texts/texts_data/classes arrays.
  4. Visit the dashboard console to verify the card shows the addon icon and name, then watch the network calls to confirm /<locale>/endpoint/dashboard-data updates your IDs.

Design Reference

Match the shared layout, spacing, and typography defined in the Dashboard Card Design Guide. That guide covers row spacing, icon usage, typography, and color recommendations.

Addon Page Endpoints

Endpoints are internal JSON APIs used by the site (usually via AJAX) to load lists, details, and other data without rendering a full HTML page. An endpoint runs server-side code and returns a PHP array named $response. The system converts that array into JSON.

Use endpoints when: You need to fetch data for your site's pages (console, content pages). Endpoints run through the full Horuph bootstrap, giving you complete access to $siteContext, authentication, translations, permissions, and all Bootstrap features.

For external services: If you're providing an API for external services, widgets, or third-party tools, use Addon APIs instead (lighter payload, partial context).

Where It Lives

Addon endpoints live here:

core/
    addons/
        my_addon/
        views/
            endpoints/
            my-endpoint.php

Core (non-addon) endpoints live here:

core/
    views/
        endpoints/
        my-endpoint.php

How Endpoints Are Routed

The router detects endpoint URLs in core/views/render.php and then dispatches them through:

core/views/endpoint.php

For addon endpoints, the URL data format is:

/{urlLocale}/{addon}/endpoint/{endpointName}

For core endpoints, the URL data format is:

/{urlLocale}/endpoint/{endpointName}

What You Have Access To

Endpoints run through the full Horuph bootstrap, so you have complete access to:

  • $siteContext - Full context including user/auth, locale, addons, URL data, and all Bootstrap values
  • Helper functions - All hp_* helpers (database, permissions, translations, etc.)
  • Authentication - User session and authentication state
  • Translations - hp_t() for server-side translations
  • Permissions - hp_verify_permission() for access control
  • Addon bootstrap data - Any data added to $siteContext by addon bootstrap files

Rules Inside Endpoint Files

  • Never call header(), exit, die, echo, or print inside an endpoint file.
  • Always build a PHP array named $response.
  • Use return; to stop early (for validation, permission failure, etc.).
  • The system outputs JSON for you.

Inputs (GET, POST, JSON Body)

You can read query parameters from $_GET as usual. The endpoint dispatcher also parses the request body:

  • If the request body is JSON, it is decoded into $data.
  • Otherwise, $data is set to $_POST.

This means endpoints can use $_GET for query strings and $data for JSON POST bodies.

Basic Endpoint Template

<?php
    $response = [
        'success' => false,
        'message' => '',
        'data' => []
    ];

    try {
        // Permission check (always gate sensitive data)
        if (!hp_verify_permission('my_addon_requests_view')) {
            $response['message'] = 'You are not authorized to view requests';
            return;
        }

        // Read inputs (example)
        $page = (int)($_GET['page'] ?? 1);
        if ($page < 1) $page = 1;

        // Do server-side work (DB queries, helpers, etc.)
        $rows = hp_db_all('SELECT id, title FROM my_table ORDER BY id DESC LIMIT 20', []);

        $response = [
            'success' => true,
            'data' => $rows,
            'page' => $page
        ];
    } catch (Exception $e) {
        $response['success'] = false;
        $response['message'] = 'Endpoint error: ' . $e->getMessage();
    }

Pagination Pattern

Most list endpoints return pagination fields. A common shape is:

{
    "success": true,
    "data": [],
    "page": 1,
    "per_page": 50,
    "total": 0,
    "total_pages": 0
    }

Recommended JSON Contract

Keep your JSON shape stable across endpoints so your frontend code stays simple.

  • success: boolean
  • message: string (optional but recommended on errors)
  • data: array/object/null (your payload)
  • error (optional): object with code and optional fields for validation

Example error shape:

{
    "success": false,
    "message": "Invalid input",
    "error": { "code": "VALIDATION", "fields": { "title": "Required" } }
    }

When to Use an Endpoint vs API vs Controller Action

  • Use an endpoint when you want to fetch data (lists, details, previews) as JSON for your site's pages (console, content). Endpoints have full Bootstrap access and complete $siteContext.
  • Use an API when you need to provide JSON data to external services or tools. APIs have lighter payload and only partial context (no $siteContext, but helper functions available).
  • Use a controller action when you want to perform an action (create, delete, retry, update). Controllers can return JSON and optionally redirect.

Tips

  • Always validate inputs (page, per_page, ids, filters) and set safe defaults.
  • Always gate sensitive data with permission checks (example: hp_verify_permission(...)).
  • Keep your response fields stable so your frontend code is easy to maintain.

Related Documentation

Addon APIs

Addon APIs are simple PHP files that return JSON via /api/.... They are designed for external integrations (services, widgets, third-party tools) where you need a lightweight payload and don't require full Horuph context.

Use APIs when: You need to provide JSON data to external services or tools. APIs have a lighter payload and only partial access to Horuph context (no full $siteContext, but helper functions like database helpers are available). APIs are essentially plain PHP files with access to helper functions.

For in-app use: If you're building JSON for your site's pages (console, content pages), use Endpoints instead. Endpoints have full Bootstrap access and complete $siteContext availability.

API endpoints are routed by the rewrite rule:

RewriteRule ^api/(.+)$ core/views/api.php?endpoint=$1 [QSA,L]

Folder Layout

core/
    addons/
        my_addon/
        views/
            api/
            stats.php
            search.php

API URL

The URL format is:

/api/{addon}/{api-file}

Extra URL parts are available as an array in $sharedPath:

/api/my_addon/search/books

In that case, $sharedPath is usually:

["books"]

How APIs Run

core/views/api.php loads these core files before including your addon API file:

  • core/classes/Router.php
  • core/classes/Helpers.php
  • core/classes/Database.php

Then it require_onces your API file and JSON-encodes its return value.

Important: APIs do not run through the full Horuph bootstrap. $siteContext is not available. Treat API files like plain PHP files with access to helper functions (database, sanitization, etc.). Use helpers directly for database operations: hp_db_one(), hp_db_all(), hp_db_insert(), etc.

APIs have a lighter payload compared to endpoints because they skip the full bootstrap process. This makes them ideal for external services that don't need the full Horuph context.

How to Create

  1. Create core/addons/my_addon/views/api/.
  2. Add a PHP file (example: test.php).
  3. Return an array at the end of the file.
  4. Open it from the browser or fetch it from JavaScript using /api/my_addon/test.

Example

// core/addons/my_addon/views/api/test.php
    <?php
    $q = hp_sanitize($_GET['q'] ?? '');

    return [
        'success' => true,
        'query' => $q,
        'timestamp' => date('c'),
    ];

Tips

  • Always return an array (so json_encode produces a JSON object).
  • Validate input ($_GET, $_POST, and path parts) before using it.
  • If the API should be private, enforce auth/permissions inside the API file (you'll need to implement your own auth check since $siteContext is not available) and return a safe error shape.
  • APIs are ideal for external services that don't need full Horuph context - they have a lighter payload and faster response time.
  • If you need full Horuph context ($siteContext, translations, permissions) for in-app use, use Endpoints instead.

Addon Windows

Addon windows are small PHP view files that can be opened as draggable pop-up windows on top of any page (console pages or content pages). They are perfect for tools, forms, previews, and info boxes without navigating away.

This uses the client-side helper WindowBox.

Folder Layout

core/
    addons/
        my_addon/
        views/
            windows/
            entry-form.php
            info-box.php

How to Create

  1. Create core/addons/my_addon/views/windows/.
  2. Add a PHP file (example: entry-form.php). The file should output only the window body HTML (no <html>, <head>, or layout wrapper).
  3. Open it with newWinbox() by pointing url to the window route.

Window URL

The window URL is /{urlLocale}/{addon}/window/{window-name}, where {window-name} is the filename without .php.

/en_us/my_addon/window/entry-form

You can also pass query parameters as usual:

/en_us/my_addon/window/entry-form?id=123

How to Open Windows

Windows are opened using the newWinbox() JavaScript function (see WindowBox for full documentation). Use SITE_DATA.urlLocale to build the correct URL.

Basic Example

function openEntryForm() {
        trans('entry_form', 'my_addon').then(title => {
            newWinbox({
                title: title,
                width: '920px',
                height: '800px',
                url: `/${SITE_DATA.urlLocale}/my_addon/window/entry-form`
            });
        });
    }

Passing Data via Query Parameters

You can pass data to windows using query parameters in the URL. The window file can read these from $_GET:

function viewRequestDetail(requestId) {
        const url = `/${SITE_DATA.urlLocale}/cloudflare/window/request-detail?id=${requestId}`;
        newWinbox({
            title: 'Request Details',
            width: '800px',
            url: url,
            onclose: function() {
                // Optional: reload data when window closes
                if (window.loadRequests) {
                    loadRequests();
                }
            }
        });
    }

Example with Dynamic Title

async function editCategory(id) {
        const isEdit = id > 0;
        const titleKey = isEdit ? 'edit_category' : 'new_category';
        
        const title = await trans(titleKey, 'my_addon');
        newWinbox({ 
            title: title, 
            width: '720px', 
            url: `/${SITE_DATA.urlLocale}/my_addon/window/category-form?id=${id}`
        });
    }

Best Practice: Read Data Server-Side (No Fetch Needed)

Important: You don't need to fetch data in JavaScript before opening a window. Instead, pass identifiers (like id or token) via query parameters and read the data directly in the window PHP file. This is faster, simpler, and more efficient.

❌ Don't do this (unnecessary fetch):

// BAD: Fetching data in JavaScript before opening window
    async function editAddon(token) {
        try {
            const response = await fetch(`/${SITE_DATA.urlLocale}/my_addon/endpoint/addon-by-token?token=${token}`);
            const result = await response.json();
            if (result.success && result.data && result.data.id) {
                openAddonForm(result.data.id);
            }
        } catch (error) {
            console.error('Error loading addon:', error);
        }
    }

✅ Do this instead (read data in window file):

// GOOD: Open window directly with token, read data server-side
    async function editAddon(token) {
        const title = await trans('edit_addon', 'my_addon');
        newWinbox({
            title: title,
            width: '600px',
            height: '500px',
            url: `/${SITE_DATA.urlLocale}/my_addon/window/addon-form?token=${encodeURIComponent(token)}`,
            onclose: function() {
                loadAddonsData(); // Reload list after closing
            }
        });
    }

And in the window file, read the data server-side:

// core/addons/my_addon/views/windows/addon-form.php
    <?php
    if (!hp_verify_permission('my_addon_manage')) {
        echo '<div class="alert alert-danger">Unauthorized</div>';
        return;
    }

    // Read identifier from query string (can be id or token)
    $addonId = isset($_GET['id']) ? (int)$_GET['id'] : 0;
    $addonToken = isset($_GET['token']) ? trim($_GET['token']) : '';
    $isEdit = $addonId > 0 || !empty($addonToken);

    // Load data if editing
    $addon = null;
    if ($isEdit) {
        if ($addonId > 0) {
            $addon = hp_db_one("SELECT * FROM my_addons WHERE id = ?", [$addonId]);
        } elseif (!empty($addonToken)) {
            $addon = hp_db_one("SELECT * FROM my_addons WHERE token = ?", [$addonToken]);
        }
        
        if ($addon) {
            $addonId = (int)$addon['id'];
        } else {
            $isEdit = false;
            $addonId = 0;
        }
    }
    ?>
    <!-- Form HTML here -->

Benefits of this approach:

  • Faster: No extra AJAX request needed
  • Simpler: All logic in one place (server-side)
  • More flexible: Window file can accept both id and token (or any identifier)
  • Better error handling: Errors can be shown directly in the window

And the window view file (reading query parameters):

// core/addons/my_addon/views/windows/entry-form.php
    <?php
    // Always check permissions at the start of window files
    if (!hp_verify_permission('my_addon_image_view')) {
        echo '<div class="alert alert-danger">You are not authorized to view this window</div>';
        exit;
    }

    // Read query parameters passed from JavaScript
    $itemId = (int)($_GET['id'] ?? 0);
    $isEdit = $itemId > 0;

    // Load data if editing
    $itemData = [];
    if ($isEdit) {
        $itemData = hp_db_one('SELECT * FROM my_table WHERE id = ?', [$itemId]);
    }
    ?>
    <div class="p-3">
        <h6 class="mb-3"><?php echo hp_t('entry_form', 'my_addon'); ?></h6>
        <form method="post" action="/<?php echo $siteContext['urlLocale']; ?>/my_addon/action/save">
            <input type="hidden" name="id" value="<?php echo $itemId; ?>">
            <input class="form-control mb-2" name="title" placeholder="Title" value="<?php echo htmlspecialchars($itemData['title'] ?? ''); ?>">
            <button class="btn btn-primary" type="submit">Save</button>
        </form>
    </div>

Security Note: Always check permissions at the beginning of window files. If the check fails, display an error message and use exit; to prevent further execution.

Predefined Confirmation Windows

Horuph provides two built-in confirmation windows for delete and other destructive actions. See Confirmation Windows for full documentation.

When to Use Which Confirm Window?

Important Rule: Choose the right confirmation window based on your action type:

  • Use confirm (Standard Confirm) when your action uses direct POST with redirect. This is the preferred pattern in admin panels. After confirmation, the form submits directly, the server processes the action, and redirects to a new page (usually the same page or a list page).
  • Use confirm-async (Async Confirm) when your action uses AJAX POST without page refresh. After confirmation, the form submits via AJAX, receives a JSON response, and updates the UI dynamically without reloading the page.

General guideline: In admin panels, confirm (direct POST) is usually preferred because it's simpler, more reliable, and provides better user feedback through redirects and toast messages.

Standard Confirm (POST Redirect)

Use this when you want a normal form POST that redirects after completion. This is the standard pattern for admin panel actions:

async function deleteVersion(versionId) {
        const title = await trans('delete', 'harph');
        const message = await trans('confirm_delete_version', 'harph');
        const returnUrl = window.location.href; // Return to current page after delete
        const url = `/${SITE_DATA.urlLocale}/window/confirm?act=harph/version-delete&q=${versionId}&return=${encodeURIComponent(returnUrl)}&message=${encodeURIComponent(message)}`;
        newWinbox({ title, width: '450px', url });
    }

How it works:

  • User clicks delete button → confirm window opens
  • User confirms → Form submits via POST to the action controller
  • Controller processes the action and redirects (usually back to the same page or a list)
  • Page reloads with updated data and shows a toast message

Controller pattern: The controller should read parameters from $_POST['q'] (or other fields), process the action, set $_SESSION['ui_alert'] for toast messages, and redirect using $redirectUrl.

Async Confirm (JSON Response)

Use this when you want to stay on the page and refresh UI dynamically after confirmation (without page reload). Set SITE_DATA.storedActions to a function that will run after successful confirmation:

When to use: Use confirm-async when you need to update the UI dynamically without reloading the page, such as removing an item from a list, updating a counter, or refreshing a specific section of the page.

async function deleteFieldConfirm(index) {
        // Store the action to run after confirmation
        SITE_DATA.storedActions = function() { 
            deleteField(index);
        };
        
        const url = `/${SITE_DATA.urlLocale}/window/confirm-async`;
        newWinbox({
            title: '<?php echo hp_t('confirm_delete_field', 'my_addon'); ?>',
            width: '420px',
            url: url
        });
    }

For async confirm with server action:

async function deleteRequest(requestId) {
        SITE_DATA.storedActions = function() {
            refreshList();
        };
        
        const title = await trans('confirm_delete');
        const url = `/${SITE_DATA.urlLocale}/window/confirm-async?act=cloudflare/request-delete&q=${requestId}`;
        newWinbox({ title, width: '420px', url });
    }

⚠️ Never Use JavaScript's Built-in Alert/Confirm

IMPORTANT: Never use JavaScript's native alert(), confirm(), or prompt() functions in your code. Always use Horuph's confirmation windows instead. See Confirmation Windows documentation for details.

❌ Wrong:

async function deleteItem() {
        if (!confirm('Are you sure?')) {
            return;
        }
        // ... delete logic
        alert('Success!');
    }

✅ Correct:

async function deleteItem() {
        SITE_DATA.storedActions = function() {
            refreshList();
        };
        
        const title = await trans('delete', 'my_addon'); // Short title
        const url = `/${SITE_DATA.urlLocale}/window/confirm-async?act=items/delete&id=${id}`;
        newWinbox({ title, width: '450px', url });
    }

Keep WindowBox Titles Short

Important: WindowBox titles have limited space in the header bar. Always use short titles (2-4 words maximum). If you need a longer confirmation message, use the message query parameter with confirm-async.

❌ Wrong (title too long):

const title = await trans('confirm_delete_key_long_message', 'harph');
    // "Are you sure you want to delete your signing key? This action cannot be undone..."

✅ Correct (short title + custom message):

const title = await trans('delete_key', 'harph'); // Short: "Delete Key"
    const message = await trans('confirm_delete_key_message', 'harph'); // Long message for body
    const url = `/${SITE_DATA.urlLocale}/window/confirm-async?act=harph/key-delete&message=${encodeURIComponent(message)}`;
    newWinbox({ title, width: '450px', url });

For error messages: Use toast notifications instead of alert():

// ❌ Wrong
    alert('Error: ' + errorMessage);

    // ✅ Correct
    if (window.showToast) {
        window.showToast(errorMessage, 'error');
    }
async function deleteRequest(requestId) {
        const title = await trans('confirm_delete');
        const url = `/${SITE_DATA.urlLocale}/window/confirm-async?act=cloudflare/request-delete&q=${requestId}`;
        newWinbox({
            title: title,
            width: '420px',
            url: url,
            onclose: function() {
                // Reload data after window closes
                loadRequests();
            }
        });
    }

Tips

  • Window content has access to the same Bootstrap UI classes used elsewhere (forms, alerts, tables, buttons).
  • If you include <script> tags inside the loaded window HTML, WindowBox executes them after load (see the WindowBox page for details).
  • Keep IDs and JS function names unique so multiple windows do not conflict.
  • Use onclose callback to refresh data or perform cleanup when a window closes.
  • Always use SITE_DATA.urlLocale when building window URLs to ensure correct locale routing.

Creating Windows with Inline HTML

You don't always need to create a separate PHP file for window content. You can build HTML directly in JavaScript and pass it to newWinbox() using the html option instead of url.

Example: Inline HTML Window

async function viewVersionDetails(versionId, version) {
        // Fetch data
        const response = await fetch(`/${SITE_DATA.urlLocale}/my_addon/endpoint/version-details?id=${versionId}`);
        const result = await response.json();
        
        if (!result.success) {
            toast('Error loading details', 'error');
            return;
        }
        
        const data = result.data;
        
        // Build HTML content
        let html = '<div class="p-3">';
        html += '<h6>Version: ' + escapeHtml(version) + '</h6>';
        
        // Add dependencies table
        if (data.dependencies && data.dependencies.length > 0) {
            html += '<h6 class="mt-3">Dependencies</h6>';
            html += '<table class="table table-sm">';
            html += '<thead><tr><th>Addon</th><th>Version</th></tr></thead>';
            html += '<tbody>';
            data.dependencies.forEach(dep => {
                html += '<tr><td>' + escapeHtml(dep.addon) + '</td>';
                html += '<td><code>' + escapeHtml(dep.version) + '</code></td></tr>';
            });
            html += '</tbody></table>';
        }
        
        html += '</div>';
        
        // Open window with HTML content
        newWinbox({
            title: 'Version Details: ' + version,
            width: '800px',
            height: '600px',
            html: html  // Use html instead of url
        });
    }

    // Helper function to escape HTML
    function escapeHtml(text) {
        const div = document.createElement('div');
        div.textContent = text;
        return div.innerHTML;
    }

When to use inline HTML vs. window files:

  • Use inline HTML for simple, dynamic content that doesn't need server-side processing or complex PHP logic
  • Use window files for forms, complex layouts, or when you need server-side data processing, permissions checking, or PHP helpers
  • Best practice: If your HTML is mostly static or requires server-side logic, use a window file. If it's simple and dynamic, inline HTML is fine.

Design Reference

When building forms inside windows, follow the patterns and best practices in the WindowBox Form Design Guide. That guide covers form layout, validation, button placement, and responsive behavior.

Shared files (Addon)

Shared files are plain PHP pages inside an addon that run with Horuph loaded, but without Horuph rendering a site layout for you. Use them for downloads, exports, image generation, or any custom response where you want full control.

Shared files are routed by the rewrite rule:

RewriteRule ^shared/(.+)$ core/views/shared.php?view=$1 [QSA,L]

Folder Layout

core/
    addons/
        my_addon/
        views/
            shared/
            download.php
            export.php

Shared URL

The URL format is:

/shared/{addon}/{page}

Extra URL parts are available as an array in $sharedPath:

/shared/my_addon/download/file.zip

In that case, $sharedPath is usually:

["file.zip"]

The raw rewrite value is also available in $_GET['view'] (example: my_addon/download/file.zip).

What You Get

Shared files run through core/bootstrap.php, so you have access to:

  • Database helpers (hp_db_one(), hp_db_all(), hp_db_insert(), ...)
  • Permission helpers (hp_verify_permission(), ...)
  • Translation helpers (hp_t(), ...)
  • Other hp_* helper functions

Important: $siteContext is only partially available in shared files. It's recommended to treat shared files like plain PHP files with access to helper functions rather than relying on full $siteContext. Use helpers directly for database operations, permissions, and translations.

Horuph does not automatically output any HTML layout here. Your Shared files page controls the entire response (download headers, JSON output, custom HTML, etc.).

How to Create

  1. Create core/addons/my_addon/views/shared/.
  2. Add a PHP file (example: download.php).
  3. Visit it using /shared/my_addon/download.
  4. If you output a file or JSON, call exit; after sending the response.

Example (Download With Auth)

This example denies access unless the user is authenticated, then returns a downloadable file.

<?php
    if (!($siteContext['authenticated'] ?? false)) {
        http_response_code(403);
        die('Access denied');
    }

    $filename = $sharedPath[0] ?? 'export.zip';

    header('Content-Type: application/zip');
    header('Content-Disposition: attachment; filename="' . $filename . '"');
    readfile('/path/to/file.zip');
    exit;

Tips

  • Always validate any path parts (like $sharedPath) before using them in file paths or queries.
  • If the shared page should be private, enforce it inside the file (auth/permissions) and return 403 when needed.
  • Prefer Shared files when you need the full Horuph context. For lightweight endpoints without bootstrap, use addon private pages.

Addon Header Files

This guide explains how to add custom HTML tags to the page header using an addon. You can add meta tags, link tags, scripts, or any other content that belongs in the <head> section.

How It Works

The main layout automatically includes all header.php files from installed addons. Each addon can have one header file that outputs HTML tags directly to the page header.

Folder Layout

core/
    addons/
        my_addon/
        header.php

How to Create

  1. Create a file named header.php in your addon folder
  2. Write PHP code that outputs HTML tags using echo or print
  3. The output will be automatically inserted into the page <head> section

Example

Here's a simple example that adds a meta tag:

<?php
    echo '<meta name="author" content="My Addon">' . "\n";
    echo '<meta name="description" content="Custom description">' . "\n";

Real-World Example

Here's a more complete example that reads from configuration and conditionally adds tags:

<?php
    $envConfig = require BASE_PATH . '/core/addons/my_addon/environment.php';
    $config = [
        'enabled' => $envConfig['enabled'] ?? 1,
        'theme_color' => $envConfig['theme_color'] ?? '#000000',
    ];

    if ($config['enabled'] == 1) {
        echo '<meta name="theme-color" content="' . htmlspecialchars($config['theme_color']) . '">' . "\n";
        echo '<link rel="stylesheet" href="/core/addons/my_addon/assets/css/custom.css">' . "\n";
    }

What You Can Add

You can add any HTML tags that belong in the <head> section:

  • Meta tags (<meta>)
  • Link tags (<link>) for stylesheets, icons, manifests, etc.
  • Script tags (<script>) for JavaScript
  • Style tags (<style>) for inline CSS
  • Any other valid HTML head content

Tips

  • Always use htmlspecialchars() when outputting user-provided or configuration values to prevent XSS attacks
  • Add a newline character ("\n") at the end of each echo for better readability in the HTML source
  • Check if your addon is enabled before adding tags, so disabled addons don't add unnecessary content
  • Load your addon's configuration from environment.php if you need settings
  • The header file is included for every page, so make sure your code is efficient
  • You can use any PHP code, including conditionals, loops, and function calls

Common Use Cases

  • Adding PWA manifest links and meta tags
  • Including addon-specific CSS or JavaScript files
  • Setting theme colors or other meta information
  • Adding Open Graph or Twitter Card meta tags
  • Including analytics or tracking scripts

This guide explains how to add custom HTML tags to the page footer using an addon. You can add script tags, inline JavaScript, or any other content that belongs before the closing </body> tag.

How It Works

The main layout automatically includes all footer.php files from installed addons. Each addon can have one footer file that outputs HTML tags directly before the closing body tag.

Folder Layout

core/
    addons/
        my_addon/
        footer.php

How to Create

  1. Create a file named footer.php in your addon folder
  2. Write PHP code that outputs HTML tags using echo or print
  3. The output will be automatically inserted before the closing </body> tag

Example

Here's a simple example that adds a script tag:

<?php
    echo '<script src="/core/addons/my_addon/assets/js/custom.js"></script>' . "\n";

Real-World Example

Here's a more complete example that reads from configuration and conditionally adds scripts:

<?php
    $envConfig = require BASE_PATH . '/core/addons/my_addon/environment.php';
    $config = [
        'enabled' => $envConfig['enabled'] ?? 1,
        'api_key' => $envConfig['api_key'] ?? '',
    ];

    if ($config['enabled'] == 1 && !empty($config['api_key'])) {
        echo '<script src="/service-worker.js"></script>' . "\n";
        echo '<script>' . "\n";
        echo '    if ("serviceWorker" in navigator) {' . "\n";
        echo '        window.addEventListener("load", () => {' . "\n";
        echo '            navigator.serviceWorker.register("/service-worker.js").catch(console.error);' . "\n";
        echo '        });' . "\n";
        echo '    }' . "\n";
        echo '</script>' . "\n";
    }

What You Can Add

You can add any HTML tags that belong before the closing </body> tag:

  • Script tags (<script>) for JavaScript files or inline code
  • Analytics tracking codes
  • Third-party widget scripts
  • Custom initialization code
  • Any other valid HTML body content

Tips

  • Always use htmlspecialchars() when outputting user-provided or configuration values to prevent XSS attacks
  • Add a newline character ("\n") at the end of each echo for better readability in the HTML source
  • Check if your addon is enabled before adding scripts, so disabled addons don't load unnecessary code
  • Load your addon's configuration from environment.php if you need settings
  • The footer file is included for every page, so make sure your code is efficient
  • Scripts in the footer load after the page content, which is better for page performance
  • You can use any PHP code, including conditionals, loops, and function calls

Common Use Cases

  • Registering service workers for PWA functionality
  • Including addon-specific JavaScript files
  • Adding analytics or tracking scripts (Google Analytics, etc.)
  • Initializing third-party widgets or plugins
  • Setting up WebSocket connections or real-time features
  • Adding custom event listeners or page-specific JavaScript

Difference from Header Files

Footer files are placed before the closing </body> tag, while header files are placed in the <head> section. Use footer files for:

  • JavaScript that needs the DOM to be ready
  • Scripts that should load after page content
  • Performance-critical scripts that shouldn't block page rendering

Use header files for:

  • CSS stylesheets
  • Meta tags
  • Preload or prefetch directives

Addon Root Files

This guide explains how to create files that are accessible from the root URL of your site. You can create JavaScript files, JSON manifests, HTML pages, or any other file type that needs to be served directly from the root path.

How It Works

When a URL like /service-worker.js or /manifest.webmanifest is requested and returns a 404 error, the system automatically searches for matching files in addon root/ folders. If found, the file is executed and its output is served.

Folder Layout

core/
    addons/
        my_addon/
        root/
            service-worker.js.php
            manifest.webmanifest.php
            custom-file.html.php

How to Create

  1. Create a folder named root inside your addon folder
  2. Create a PHP file with the desired filename plus .php extension
  3. For example, to serve /service-worker.js, create root/service-worker.js.php
  4. Write PHP code that outputs the desired content
  5. Set appropriate headers if needed (Content-Type, etc.)

URL Mapping

The filename determines the URL path:

  • File: root/service-worker.js.php → URL: /service-worker.js
  • File: root/manifest.webmanifest.php → URL: /manifest.webmanifest
  • File: root/offline.html.php → URL: /offline.html

The system removes the .php extension from the filename to create the URL path.

Example: JavaScript File

Create a service worker JavaScript file:

<?php
    header('Content-Type: application/javascript');

    $envConfig = require BASE_PATH . '/core/addons/my_addon/environment.php';
    $cacheVersion = $envConfig['cache_version'] ?? 'v1';

    echo "const CACHE_VERSION = '{$cacheVersion}';\n";
    echo "const CACHE_NAME = 'my-addon-' + CACHE_VERSION;\n\n";
    echo "self.addEventListener('install', function(event) {\n";
    echo "    event.waitUntil(\n";
    echo "        caches.open(CACHE_NAME).then(function(cache) {\n";
    echo "            return cache.addAll(['/', '/offline.html']);\n";
    echo "        })\n";
    echo "    );\n";
    echo "});\n";

Example: JSON Manifest

Create a web app manifest file:

<?php
    header('Content-Type: application/manifest+json');

    $envConfig = require BASE_PATH . '/core/addons/my_addon/environment.php';
    $manifest = [
        'name' => $envConfig['app_name'] ?? 'My App',
        'short_name' => $envConfig['short_name'] ?? 'App',
        'start_url' => $envConfig['start_url'] ?? '/',
        'display' => 'standalone',
        'icons' => [
            [
                'src' => '/icon.png',
                'sizes' => '192x192',
                'type' => 'image/png'
            ]
        ]
    ];

    echo json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);

Example: HTML Page

Create an offline HTML page:

<?php
    header('Content-Type: text/html; charset=utf-8');

    $envConfig = require BASE_PATH . '/core/addons/my_addon/environment.php';
    $offlineMessage = $envConfig['offline_message'] ?? 'You are offline';
    ?>
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Offline</title>
    </head>
    <body>
        <h1><?php echo htmlspecialchars($offlineMessage); ?></h1>
        <p>Please check your internet connection.</p>
    </body>
    </html>

Setting Headers

Always set the appropriate Content-Type header for your file type:

  • JavaScript: header('Content-Type: application/javascript');
  • JSON: header('Content-Type: application/json');
  • HTML: header('Content-Type: text/html; charset=utf-8');
  • CSS: header('Content-Type: text/css');
  • XML: header('Content-Type: application/xml');

Tips

  • Root files are only checked when the URL returns a 404 error and has no slug (second path segment)
  • Use htmlspecialchars() when outputting user-provided or configuration values to prevent XSS attacks
  • Load your addon's configuration from environment.php to make files dynamic
  • You can use any PHP code, including conditionals, loops, and function calls
  • Root files are executed directly, so they have access to all PHP variables and functions
  • For JSON output, use JSON_PRETTY_PRINT and JSON_UNESCAPED_SLASHES flags for better readability
  • Remember to set headers before any output, or use output buffering if needed

Common Use Cases

  • Service worker files for Progressive Web Apps (PWA)
  • Web app manifest files (manifest.webmanifest)
  • Offline pages or error pages
  • API configuration files
  • Robots.txt or sitemap.xml files
  • Custom JavaScript files that need to be in the root

Important Notes

  • Root files must have a .php extension, even if they output non-PHP content
  • The URL path is derived from the filename by removing the .php extension
  • Only files in the root/ folder are checked, not in subfolders
  • If multiple addons have files with the same name, the first one found will be used
  • Root files are executed in the order they are found by glob, which may vary

Part 5: Addon Other Topics

Addon Controllers

Controllers handle POST/JSON requests for an addon. Each controller is a single PHP file stored under core/addons/<addon>/controllers.

How They Are Called

  • URL format: /{urlLocale}/{addon}/action/{controllerName}
  • {controllerName} is the filename without .php.
  • Example: /en_us/<addon_name>/action/content-save loads core/addons/<addon_name>/controllers/content-save.php.
  • Request body can be form data ($_POST) or JSON (decoded into $data by the action router).

Controller Template

  1. Create the file inside your addon’s controllers directory.
  2. Read/sanitize input, run your logic, and store the result in a $response array.
  3. Never echo output or set headers; the router finishes the response for you.

Response Rules

  • $response must always include:
    • success (boolean)
    • message (string)
  • If you include redirect (URL string), the router issues an HTTP redirect after the controller finishes.
  • If you omit redirect, the router returns $response as JSON automatically.
  • You can add extra keys (e.g., data, errors) when you want to send structured JSON back.

Redirect Pattern (Best Practice)

Important: When using windowbox forms or when you want consistent redirect behavior, follow this pattern:

  1. Define redirect URL at the top of your controller (before the try block) so it's available for both success and failure cases:
  2. <?php
        // Define redirect URL (will be used for both success and failure)
        $redirectUrl = hp_url($siteContext['locale'], ['addon-console', 'my_addon']);
    
        $response = ['success' => false, 'message' => '', 'data' => null];
    
        try {
            // ... your code ...
        }
  3. Always include redirect in both success and failure responses when using windowbox forms or when you want to redirect users after actions:
  4. // Success case
        $response = [
            'success' => true,
            'message' => 'Item saved successfully',
            'redirect' => $redirectUrl
        ];
        $_SESSION['ui_alert'] = 'edit'; // or 'add' for new items
    
        // Failure case
        $response = [
            'success' => false,
            'message' => 'Error occurred',
            'error' => ['code' => 'INTERNAL_ERROR'],
            'redirect' => $redirectUrl
        ];
        $_SESSION['ui_alert'] = 'error';
  5. Set toast alerts using $_SESSION['ui_alert'] for user feedback:
    • 'add' - When creating new items
    • 'edit' - When updating existing items
    • 'error' - For errors
    • 'not_found' - When item not found
    • Other predefined toasts (see "Setting Toast Alerts" section below)
⚠️ Important:
  • If you're using windowbox forms, you should always redirect (both success and failure) to close the window and return to the console.
  • If you're returning JSON for AJAX calls, you can omit redirect.
  • Defining $redirectUrl at the top ensures consistency and makes it easy to update if needed.

Recommended JSON Contract

To keep your addon consistent with core addons and easy for clients/AI to consume, prefer this shape:

{
    "success": true,
    "message": "Saved",
    "data": { }
    }

On validation errors:

{
    "success": false,
    "message": "Invalid input",
    "error": { "code": "VALIDATION", "fields": { "title": "Required" } }
    }

Notes:

  • Use error.code for machine handling (example: FORBIDDEN, NOT_FOUND, VALIDATION).
  • Use error.fields for per-field messages when validating forms.
  • If you redirect, you can still set $_SESSION['ui_alert'] for UI feedback.

Setting Toast Alerts (Optional)

When you plan to redirect after the action, set $_SESSION['ui_alert'] to control which toast appears on the next page.

Using Predefined Toasts

Set it to a string using one of these predefined values: 'success', 'error', 'add', 'edit', 'delete', 'not_found', 'required_fields', 'warning', 'login_error', 'login_success', 'logged_out'.

$_SESSION['ui_alert'] = 'success';

Leave $_SESSION['ui_alert'] unset if you don't want any toast.

Complete Example

Here's a complete example showing the best practice pattern with redirect URL defined at the top and used consistently:

<?php
    // Define redirect URL (will be used for both success and failure)
    $redirectUrl = hp_url($siteContext['locale'], ['addon-console', 'my_addon']);

    $response = ['success' => false, 'message' => '', 'data' => null];

    try {
    // Check permission first
    if (!hp_verify_permission('my_addon_file_upload')) {
        $response = [
            'success' => false,
            'message' => 'You are not authorized to upload files',
                'error' => ['code' => 'FORBIDDEN'],
                'redirect' => $redirectUrl
        ];
        $_SESSION['ui_alert'] = 'error';
        return;
    }

        // Sanitize input
        $data = hp_sanitize($data);
        
        // Validate
        $campaignId = isset($data['campaign_id']) ? (int)$data['campaign_id'] : 0;
    if ($campaignId <= 0) {
        $response = [
                'success' => false,
                'message' => 'Campaign ID is required',
                'error' => ['code' => 'VALIDATION'],
                'redirect' => $redirectUrl
        ];
            $_SESSION['ui_alert'] = 'error';
        return;
    }

    // ...perform your action...
        
        // Success
        $response = [
            'success' => true,
            'message' => 'Campaign saved successfully',
            'data' => ['campaign_id' => $campaignId],
            'redirect' => $redirectUrl
        ];
        $_SESSION['ui_alert'] = $campaignId > 0 ? 'edit' : 'add';
        
    } catch (Exception $e) {
        hp_log('Campaign Save Error', [
            'error' => $e->getMessage(),
            'file' => $e->getFile(),
            'line' => $e->getLine()
        ], 'ERROR');

    $response = [
            'success' => false,
            'message' => 'An error occurred. Please try again.',
            'error' => ['code' => 'INTERNAL_ERROR'],
            'redirect' => $redirectUrl
    ];
        $_SESSION['ui_alert'] = 'error';
    }
    ?>

Required Response Fields for Activity Logging

Important: Every controller must always include success and message in the $response array, even when redirecting. Horuph uses these fields to automatically log all controller actions to the activity log. See Activity Log for detailed information.

  • success (boolean) - Required. Must be present in every response.
  • message (string) - Required. Must be present in every response (can be empty string on success).
  • redirect (string, optional) - Include this if you want to redirect instead of returning JSON.

Even in exception handlers, you must set success and message:

<?php
    try {
        // ... your code ...
        $response = [
            'success' => true,
            'message' => 'Action completed'
        ];
    } catch (Exception $e) {
        hp_log('Error', ['error' => $e->getMessage()], 'ERROR');
        $response = [
            'success' => false,
            'message' => 'Error: ' . $e->getMessage(),  // Always include success and message
            'redirect' => hp_url($siteContext['locale'], ['console'])
        ];
    }
    ?>

Related Documentation

Dos and Don'ts

  • ✅ Use $response for every exit path (including validation errors).
  • ✅ Always include success and message in every response (required for activity logging).
  • ✅ Return extra data via $response['data'] if the client expects JSON.
  • Define redirect URL at the top when using windowbox forms or when you need consistent redirects.
  • Always include redirect in both success and failure when using windowbox forms.
  • Set $_SESSION['ui_alert'] for user feedback when redirecting (use 'add', 'edit', 'error', etc.).
  • ✅ Set $response['redirect'] when you want to send the user somewhere else after success or failure.
  • ✅ Always set $response in exception handlers (catch blocks).
  • ❌ Do not echo JSON manually or send headers; the router handles it.
  • ❌ NEVER exit.
  • ❌ Do not skip success and message even when redirecting — activity logging happens before redirect.
  • ❌ Don't forget to set redirect in failure cases when using windowbox forms.

Error Handling Patterns

This guide explains how to handle errors consistently in controllers and endpoints. Following these patterns ensures your addon provides clear error messages to users while logging detailed information for debugging.

Error Response Structure

All error responses should follow this structure:

{
    "success": false,
    "message": "Human-readable error message",
    "error": {
        "code": "ERROR_CODE",
        "fields": { "field_name": "Field-specific error message" }
    }
    }

Response Contract (Controllers vs Endpoints)

Both controllers and endpoints must return a JSON response array. The router handles output.

  • Required keys: success (boolean), message (string)
  • Optional: data (any), error (object), redirect (URL string, controllers only)
  • Controllers: set $response and optionally redirect; the action router returns JSON or redirects.
  • Endpoints: set $response; do not echo JSON or set headers.
  • HTTP status: endpoints use $siteContext['httpStatus'] for http_response_code.

Standard Error Codes

Use these error codes consistently across your addon. Error codes help frontend code handle errors programmatically.

Common Error Codes

  • VALIDATION - Input validation failed. Always include error.fields with field-specific messages.
  • FORBIDDEN - User doesn't have permission to perform the action.
  • NOT_FOUND - Requested resource (item, record, file) doesn't exist.
  • DATABASE_ERROR - Database operation failed (connection, query, constraint violation).
  • INTERNAL_ERROR - Unexpected server error. Use this for unhandled exceptions.
  • UNAUTHORIZED - User is not authenticated (not logged in).
  • CONFLICT - Resource conflict (e.g., duplicate entry, concurrent modification).
  • BAD_REQUEST - Invalid request format or missing required parameters.

Database Error Handling

Always wrap database operations in try-catch blocks. Log detailed error information but return user-friendly messages.

Pattern for Controllers

<?php
    try {
        // Validate input first
        $title = hp_sanitize($data['title'] ?? '');
        if ($title === '') {
            $response = [
                'success' => false,
                'message' => 'Title is required',
                'error' => [
                    'code' => 'VALIDATION',
                    'fields' => ['title' => 'Title is required']
                ]
            ];
            return;
        }

        // Perform database operation
        $id = hp_db_exec('INSERT INTO my_table (title) VALUES (?)', [$title]);
        if (!$id) {
            $dbError = Database::getInstance()->getLastError();
            hp_log('Database insert failed', [
                'error' => json_encode($dbError),
                'query' => 'INSERT INTO my_table',
                'data' => ['title' => $title]
            ], 'ERROR');
            
            $response = [
                'success' => false,
                'message' => 'Failed to save item. Please try again.',
                'error' => ['code' => 'DATABASE_ERROR']
            ];
            return;
        }

        $response = [
            'success' => true,
            'message' => 'Item saved successfully',
            'data' => ['id' => $id]
        ];
        
    } catch (Exception $e) {
        // Log full error details for debugging
        hp_log('Controller error', [
            'error' => $e->getMessage(),
            'file' => $e->getFile(),
            'line' => $e->getLine(),
            'trace' => $e->getTraceAsString()
        ], 'ERROR');
        
        // Return user-friendly message
        $response = [
            'success' => false,
            'message' => 'An error occurred. Please try again later.',
            'error' => ['code' => 'INTERNAL_ERROR']
        ];
    }
    ?>

Pattern for Endpoints

<?php
    $response = ['success' => false, 'message' => '', 'data' => []];

    try {
        // Permission check
        if (!hp_verify_permission('my_addon_items_view')) {
            $response = [
                'success' => false,
                'message' => 'You are not authorized to view items',
                'error' => ['code' => 'FORBIDDEN']
            ];
            return;
        }

        // Database query
        $items = hp_db_all('SELECT id, title FROM my_table WHERE status = ? ORDER BY id DESC', ['active']);
        
        if ($items === false) {
            $dbError = Database::getInstance()->getLastError();
            hp_log('Database query failed', [
                'error' => json_encode($dbError),
                'query' => 'SELECT id, title FROM my_table'
            ], 'ERROR');
            
            $response = [
                'success' => false,
                'message' => 'Failed to load items',
                'error' => ['code' => 'DATABASE_ERROR']
            ];
            return;
        }

        $response = [
            'success' => true,
            'data' => $items
        ];
        
    } catch (Exception $e) {
        hp_log('Endpoint error', [
            'error' => $e->getMessage(),
            'file' => $e->getFile(),
            'line' => $e->getLine()
        ], 'ERROR');
        
        $response = [
            'success' => false,
            'message' => 'Error loading data',
            'error' => ['code' => 'INTERNAL_ERROR']
        ];
    }
    ?>

Validation Error Messages

Validation errors should be clear, specific, and user-friendly. Always use the VALIDATION error code with error.fields for field-specific messages.

Basic Validation Pattern

<?php
    $errors = [];
    $title = hp_sanitize($data['title'] ?? '');
    $email = hp_sanitize($data['email'] ?? '');

    // Validate title
    if ($title === '') {
        $errors['title'] = 'Title is required';
    } elseif (strlen($title) < 3) {
        $errors['title'] = 'Title must be at least 3 characters';
    } elseif (strlen($title) > 100) {
        $errors['title'] = 'Title must not exceed 100 characters';
    }

    // Validate email
    if ($email === '') {
        $errors['email'] = 'Email is required';
    } elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
        $errors['email'] = 'Please enter a valid email address';
    }

    // Return validation errors if any
    if (!empty($errors)) {
        $response = [
            'success' => false,
            'message' => 'Please correct the errors below',
            'error' => [
                'code' => 'VALIDATION',
                'fields' => $errors
            ]
        ];
        return;
    }

    // Continue with processing...
    ?>

Advanced Validation with Multiple Rules

<?php
    function validateItem($data) {
        $errors = [];
        
        // Title validation
        $title = trim($data['title'] ?? '');
        if (empty($title)) {
            $errors['title'] = 'Title is required';
        } elseif (strlen($title) < 3) {
            $errors['title'] = 'Title must be at least 3 characters long';
        } elseif (strlen($title) > 200) {
            $errors['title'] = 'Title cannot exceed 200 characters';
        }
        
        // Price validation
        $price = $data['price'] ?? null;
        if ($price === null || $price === '') {
            $errors['price'] = 'Price is required';
        } elseif (!is_numeric($price)) {
            $errors['price'] = 'Price must be a number';
        } elseif ($price < 0) {
            $errors['price'] = 'Price cannot be negative';
        } elseif ($price > 999999.99) {
            $errors['price'] = 'Price is too large';
        }
        
        // Category validation (must exist in database)
        $categoryId = (int)($data['category_id'] ?? 0);
        if ($categoryId <= 0) {
            $errors['category_id'] = 'Please select a category';
        } else {
            $category = hp_db_one('SELECT id FROM categories WHERE id = ?', [$categoryId]);
            if (!$category) {
                $errors['category_id'] = 'Selected category does not exist';
            }
        }
        
        return $errors;
    }

    // In controller:
    $validationErrors = validateItem($data);
    if (!empty($validationErrors)) {
        $response = [
            'success' => false,
            'message' => 'Please correct the validation errors',
            'error' => [
                'code' => 'VALIDATION',
                'fields' => $validationErrors
            ]
        ];
        return;
    }
    ?>

Best Practices for Validation Messages

  • Be specific: Instead of "Invalid input", say "Title is required" or "Email format is invalid".
  • Be helpful: Include guidance like "Title must be at least 3 characters" or "Price must be between 0 and 999999.99".
  • Use consistent language: Keep message tone and style consistent across your addon.
  • Validate early: Check all required fields and formats before attempting database operations.
  • Return all errors at once: Don't stop at the first error; collect all validation errors and return them together.
  • Use translations: For user-facing messages, use hp_t() with your addon's language pack.

Permission and Authorization Errors

Always check permissions before performing sensitive operations. Return FORBIDDEN when access is denied.

<?php
    // Check permission at the start
    if (!hp_verify_permission('my_addon_items_delete')) {
        $response = [
            'success' => false,
            'message' => 'You do not have permission to delete items',
            'error' => ['code' => 'FORBIDDEN']
        ];
        return;
    }

    // Check if resource exists
    $item = hp_db_one('SELECT id FROM my_table WHERE id = ?', [$itemId]);
    if (!$item) {
        $response = [
            'success' => false,
            'message' => 'Item not found',
            'error' => ['code' => 'NOT_FOUND']
        ];
        return;
    }

    // Proceed with deletion...
    ?>

Not Found Errors

When a requested resource doesn't exist, return NOT_FOUND with a clear message.

<?php
    $itemId = (int)($_GET['id'] ?? 0);
    $item = hp_db_one('SELECT * FROM my_table WHERE id = ?', [$itemId]);

    if (!$item) {
        $response = [
            'success' => false,
            'message' => 'Item not found',
            'error' => ['code' => 'NOT_FOUND']
        ];
        return;
    }

    $response = [
        'success' => true,
        'data' => $item
    ];
    ?>

Error Logging Best Practices

  • Always log errors: Use hp_log() to log all errors with context for debugging.
  • Include context: Log relevant data (IDs, query strings, user info) but never log sensitive data (passwords, tokens).
  • Use appropriate log levels: Use ERROR for failures, WARNING for concerns.
  • Don't expose technical details: Log full error details (stack traces, SQL errors) but return user-friendly messages.
  • Log before returning: Always log errors before setting the response and returning.

Complete Example: Controller with Full Error Handling

<?php
    $response = ['success' => false, 'message' => '', 'data' => null];

    try {
        // 1. Permission check
        if (!hp_verify_permission('my_addon_items_edit')) {
            $response = [
                'success' => false,
                'message' => 'You do not have permission to edit items',
                'error' => ['code' => 'FORBIDDEN']
            ];
            return;
        }

        // 2. Validate input
        $itemId = (int)($data['id'] ?? 0);
        $title = hp_sanitize($data['title'] ?? '');
        
        if ($itemId <= 0) {
            $response = [
                'success' => false,
                'message' => 'Invalid item ID',
                'error' => ['code' => 'BAD_REQUEST']
            ];
            return;
        }
        
        if ($title === '') {
            $response = [
                'success' => false,
                'message' => 'Title is required',
                'error' => [
                    'code' => 'VALIDATION',
                    'fields' => ['title' => 'Title is required']
                ]
            ];
            return;
        }

        // 3. Check if item exists
        $existing = hp_db_one('SELECT id FROM my_table WHERE id = ?', [$itemId]);
        if (!$existing) {
            $response = [
                'success' => false,
                'message' => 'Item not found',
                'error' => ['code' => 'NOT_FOUND']
            ];
            return;
        }

        // 4. Perform database update
        $result = hp_db_exec('UPDATE my_table SET title = ? WHERE id = ?', [$title, $itemId]);
        if (!$result) {
            $dbError = Database::getInstance()->getLastError();
            hp_log('Database update failed', [
                'error' => json_encode($dbError),
                'item_id' => $itemId,
                'title' => $title
            ], 'ERROR');
            
            $response = [
                'success' => false,
                'message' => 'Failed to update item. Please try again.',
                'error' => ['code' => 'DATABASE_ERROR']
            ];
            return;
        }

        // 5. Success response
        $response = [
            'success' => true,
            'message' => 'Item updated successfully',
            'data' => ['id' => $itemId]
        ];
        
    } catch (Exception $e) {
        // 6. Handle unexpected errors
        hp_log('Unexpected error in item update', [
            'error' => $e->getMessage(),
            'file' => $e->getFile(),
            'line' => $e->getLine(),
            'trace' => $e->getTraceAsString()
        ], 'ERROR');
        
        $response = [
            'success' => false,
            'message' => 'An unexpected error occurred. Please try again later.',
            'error' => ['code' => 'INTERNAL_ERROR']
        ];
    }
    ?>

Summary

  • Always use try-catch: Wrap database operations and risky code in try-catch blocks.
  • Log before returning: Use hp_log() to log errors with context before setting error responses.
  • Use standard error codes: Choose appropriate error codes from the standard list.
  • Return user-friendly messages: Don't expose technical details to users.
  • Validate early: Check permissions, validate input, and verify resources exist before processing.
  • Include field errors: For validation errors, use error.fields to provide field-specific messages.

Related Documentation

Addon Helpers

Helpers are plain PHP functions that you want available everywhere (controllers, layouts, workers, etc.). Each addon can ship a support/helpers.php file and every function inside becomes global automatically.

Addons always live under core/addons/. Anything else in core/ belongs to the core itself, so these instructions apply only to addon code (there is no special "core addon" category).

Folder Layout

core/
    addons/
        my_addon/
        support/
            helpers.php

How Helpers Load

core/classes/Supports.php loops through all addons during bootstrap and includes support/helpers.php when it exists. You do not need to require the file yourself - every function you define is ready to call anywhere in the project once the addon is enabled.

How to Create

  1. Create the support folder (if it does not exist) inside your addon.
  2. Add a helpers.php file.
  3. Define your helper functions directly in that file. Keep names unique to avoid collisions (prefix with the addon name if needed).
  4. Use the helpers anywhere: controllers, views, layouts, and other helpers.

Example

<?php
    // core/addons/my_addon/support/helpers.php (the file is automatically loaded in bootstrap)

    function my_addon_format_name(string $name): string {
        return ucwords(strtolower(trim($name)));
    }

    function my_addon_log($message) {
        hp_log('[MyAddon] ' . $message, [], 'WARNING');
    }
    ?>

After creating this file, you can call my_addon_format_name() anywhere without another include.

To surface notification badges for your addon, define helpers named [addon]_notifications_count() as described in the Addon Notification Counts guide.

Best Practices

  • Group related functions (formatters, database helpers, API wrappers) so other developers can discover them easily.
  • ⚠️ Never use if (!function_exists('helper_name')): Helper functions must be unique. If a function with the same name already exists, the system should throw an error immediately rather than silently using a different implementation. This prevents bugs caused by conflicting function definitions with different behaviors. Always define functions directly without the function_exists check.
  • Keep helpers stateless; pass everything through parameters so they stay easy to test.
  • When a helper depends on addon settings, load the environment file inside the helper (e.g., $env = require .../environment.php).
  • Use unique function names prefixed with your addon name to avoid collisions (e.g., my_addon_format_name instead of format_name).

❌ Wrong Way (Don't Do This)

<?php
    // BAD: Using function_exists check
    if (!function_exists('my_addon_format_name')) {
        function my_addon_format_name(string $name): string {
            return ucwords(strtolower(trim($name)));
        }
    }
    ?>

Common Failure Modes & Recovery

  • VALIDATION: return field errors and keep the user on the form.
  • UNAUTHORIZED: prompt login or redirect to login.
  • FORBIDDEN: show a clear permission message.
  • NOT_FOUND: show a not found message and offer reload.
  • CONFLICT: ask user to refresh or retry.
  • DATABASE_ERROR / INTERNAL_ERROR: show generic error, log details, allow retry.

Testing & Debugging (Addon Dev)

This is a simple, repeatable workflow to test and debug addons while developing.

Quick Testing Steps

  1. Enable Developer Mode so new addons load without install.
  2. Open your addon console: /{locale}/addon-console/<addon>.
  3. Test endpoints directly in the browser or DevTools: /{locale}/<addon>/endpoint/<name>.
  4. Test controllers via form submit or fetch POST: /{locale}/<addon>/action/<name>.
  5. Verify JSON shape: success + message always present.

Where to See Errors

  • Console error logs: Console -> Dashboard -> Error Logs (reads storage/logs/horuph.log).
  • Activity logs: Console -> Logs (if save_logs is enabled).
  • PHP logs: use hp_log() in your addon code for structured errors.
  • Browser DevTools: watch network responses for endpoint/action failures.

Definition of Done (Addon)

  • Addon loads without PHP errors in both content and console.
  • All required language keys exist ([addon], [addon]_description).
  • Migrations run cleanly on install/update (no errors in logs).
  • Endpoints return valid JSON and permission checks work.
  • Controllers return success/message and redirect behavior works.
  • Uninstall works (backup created, tables removed, optional uninstall hook ok).

Why this is wrong: If another addon or file defines the same function with different behavior, the system will silently use the first definition and ignore yours, leading to unexpected bugs that are hard to debug.

✅ Correct Way (Do This)

<?php
    // GOOD: Direct function definition
    function my_addon_format_name(string $name): string {
        return ucwords(strtolower(trim($name)));
    }
    ?>

Why this is correct: If a function with the same name already exists, PHP will throw a fatal error immediately, making the conflict obvious and preventing silent bugs. This ensures function names are unique and behavior is predictable.

Examples

Any addon can drop its own support/helpers.php to publish shared helpers (formatters, database utilities, notifications, etc.). Browse your installed addons to see living examples.

Browse installed addons to see real helper patterns in production code.

Addon Console Permissions

Horuph provides a standard permission system that allows addons to define their own permission keys. By creating a _permissions.php file in your console folder, you enable permission management addons to discover and manage access control for your addon's features.

Folder Layout

core/
    addons/
        my_addon/
        views/
            console/
            _permissions.php

How to Create

  1. Create core/addons/my_addon/views/console/_permissions.php inside your console folder.
  2. Return an array of permission keys as strings.
  3. Follow the naming convention: addon_name_action_name (lowercase with underscores).
  4. Permission keys must start with your addon name followed by an underscore.

Permission Key Naming Convention

All permission keys must follow this pattern:

  • Start with your addon name (the same as your folder name)
  • Separate words with underscores
  • Use lowercase letters only
  • Use clear, descriptive action names (e.g., view, create, edit, delete, export)

Format: [addon_name]_[action] or [addon_name]_[resource]_[action]

Example File

<?php
    return [
        'my_addon_console_access',
        'my_addon_reports_view',
        'my_addon_reports_export',
        'my_addon_settings_edit',
        'my_addon_items_create',
        'my_addon_items_edit',
        'my_addon_items_delete',
    ];
    ?>

Real-World Examples

Users Addon

<?php
    return [
        "users_view",
        "users_create",
        "users_edit",
        "users_delete",
        "users_roles_view",
        "users_roles_create",
        "users_roles_edit",
        "users_roles_delete",
    ];
    ?>

Mail Service Addon

<?php
    return [
        "mail_service_settings_update",
        "mail_service_reports_view",
        "mail_service_mails_view",
        "mail_service_mails_delete",
        "mail_service_mails_resend",
    ];
    ?>

Cookies Addon

<?php
    return [
        "cookies_consent_settings_update",
        "cookies_category_create",
        "cookies_category_edit",
        "cookies_category_delete",
        "cookies_provider_create",
        "cookies_provider_edit",
        "cookies_provider_delete",
    ];
    ?>

Contents Addon

<?php
    return [
        "contents_data_types_view",
        "contents_data_types_create",
        "contents_data_types_edit",
        "contents_data_types_delete",
        "contents_content_types_view",
        "contents_content_types_create",
        "contents_content_types_edit",
        "contents_content_types_delete",
        "contents_view",
        "contents_create",
        "contents_edit",
        "contents_delete",
        "contents_publish",
        "contents_unpublish",
        "contents_versions_view",
        "contents_versions_delete",
        "contents_taxonomy_view",
        "contents_taxonomy_create",
        "contents_taxonomy_edit",
        "contents_taxonomy_delete",
    ];
    ?>

Cloudflare Addon

<?php
    return [
        "cloudflare_requests_view",
        "cloudflare_requests_delete",
    ];
    ?>

Using Permissions in Your Code

After defining permissions in _permissions.php, use the same permission keys consistently across all contexts: console tabs, controllers, endpoints, and windows. Use hp_verify_permission() to check access.

Using the Same Permission Key Across Contexts

Define a permission key once in _permissions.php, then use it consistently everywhere. Here's how contents_delete is used across different contexts in the Contents addon:

1. Console Tab (UI Display):

<?php
    // In contents/views/console/contents.php
    if (hp_verify_permission('contents_delete')) {
        echo '<button onclick="deleteContent(' . $content['id'] . ')">Delete</button>';
    }
    ?>

2. Controller (Action Handler):

<?php
    // In contents/controllers/content-delete.php
    if (!hp_verify_permission('contents_delete')) {
        $response = [
            'success' => false,
            'message' => 'Unauthorized',
            'redirect' => hp_url($siteContext['locale'], ['console', 'contents'])
        ];
        $_SESSION['ui_alert'] = 'error';
        return;
    }
    // ... perform delete action
    ?>

3. Endpoint (Data Fetch):

<?php
    // In cloudflare/views/endpoints/requests-list.php
    if (!hp_verify_permission('cloudflare_requests_view')) {
        $response['message'] = 'You are not authorized to view requests';
        return;
    }
    // ... fetch and return data
    ?>

4. Window (Modal/Popup):

<?php
    // In cloudflare/views/windows/image-detail.php
    if (!hp_verify_permission('cloudflare_requests_view')) {
        echo '<div class="alert alert-danger">You are not authorized to view this window</div>';
        exit;
    }
    // ... display window content
    ?>

5. Console Tab Visibility (Optional):

<?php
    // In my_addon/views/console/_config.php
    return [
        'tabs' => [
            [
                'file' => 'reports',
                'name' => 'Reports',
                'icon' => 'bi bi-graph-up',
                'hidden' => (!hp_verify_permission('my_addon_reports_view') ? 1 : 0),
            ],
        ],
    ];
    ?>

By using the same permission key (contents_delete, cloudflare_requests_view, etc.) across all contexts, you ensure consistent access control. Permission management addons can discover these keys from _permissions.php and allow administrators to assign them to roles.

See hp_verify_permission for detailed usage examples. For context-specific patterns, see Addon Consoles (console tabs), Controllers (actions), Endpoints (data fetching), and Windows (modals/popups).

How Permission Management Works

Horuph's permission system works as follows:

  • Owner user: The owner user automatically has all permissions. Horuph core checks this first.
  • Permission manager addon: If a permission manager addon is installed and configured (via $siteContext['permission_manager']), it can read your _permissions.php file to discover available permissions and allow administrators to assign them to roles.
  • Standard interface: Always use hp_verify_permission('permission_key') in your code. The core automatically handles delegation to the permission manager when available.

This allows permission management addons (like a users addon) to provide role and permission management interfaces without needing to modify each individual addon.

Tips

  • Define permissions for all sensitive actions in your console (view, create, edit, delete, export, etc.).
  • Keep permission names consistent with your addon's functionality.
  • Use descriptive action names so administrators understand what each permission allows.
  • If your addon doesn't need fine-grained permissions, you can still create a minimal _permissions.php with just console access (e.g., my_addon_console_access).
  • Remember: permission keys must always start with your addon name followed by an underscore.

Related Documentation

URL Mapping (urls addon)

URL mapping and redirects were moved to the urls addon. See URLs Addon Dev Help for the current behavior, helpers, and route hooks.

Addon Render (Page-Build Pipeline)

If you want code to run only when Horuph is building a page (console or content), add it to your addon's render file. This is different from addon bootstrap: render runs during the view rendering pipeline, not during actions/endpoints/workers.

File Location

Create this file inside your addon:

core/addons/<addon_name>/views/render.php

When It Runs (And When It Does Not)

  • Runs when a request is handled by the render router (core/views/render.php) and continues to build a view.
  • Does not run for controllers/actions (.../action/...) because render exits into core/controllers/action.php.
  • Does not run for endpoints (.../endpoint/...) because render exits into core/views/endpoint.php.
  • Does not run in workers/jobs; use addon bootstrap (views/bootstrap.php) for worker context.

Important Behavior

  • Your addon's views/render.php is included on every rendered page, because core loops over all installed addons and includes their render files if they exist.
  • Keep it fast and always guard your logic (do not run heavy queries on every page load).
  • During render include-time, $siteContext['addon'] may still be empty; if you want to run only on your addon's pages, check the URL section.

How to Use It

  1. Create core/addons/<addon_name>/views/render.php.
  2. Write safe logic that reads/modifies $siteContext (no HTML output).
  3. Guard execution by page type, URL section, or auth state.

Common Guards

<?php
    // Only when user is logged in
    if (empty($siteContext['authenticated'])) {
        return;
    }

    // Only on your addon's pages (recommended guard)
    if (empty($siteContext['urlData'][0]) || $siteContext['urlData'][0] !== '<addon_name>') {
        return;
    }
    

What It Is Good For

  • Session validation, auto logout, account state checks (before building console/content UI).
  • Redirecting away from pages when a view should not be visible.
  • Preparing lightweight view-only context (flags, counts, small cached lookups) for layouts and blocks.

Addon Toolbar Icons

Horuph's admin toolbar keeps every tool in one bar: a quick-access strip (the "bar" section) plus a dropdown that contains local and global tools. Each addon can expose zero, one, or two icons: one inside the tools dropdown (either local or global) and one optional icon directly on the bar.

Toolbar Overview

The toolbar separates tools by scope:

  • Local tools: Features whose data depends on the current locale (for example, content editors or locale-bound settings). These appear inside the tools dropdown with a language badge.
  • Global tools: Features that show the same data regardless of locale (for example, newsletters or shared services). These also live in the tools dropdown but without locale badges.
  • Bar icons: High-priority actions that deserve a persistent button on the toolbar itself (outside the dropdown). These icons can open windows, trigger scripts, or link anywhere.

Folder Layout

core/
    addons/
        my_addon/
        manifest.php
        views/
            toolbar/
            icon_bar.php      <!-- required only when toolbar.bar = true -->
        ...

Manifest Settings

All toolbar visibility is controlled from manifest.php. Set the toolbar array to declare which sections your addon should appear in. Remember: choose at most one dropdown slot (local or global) plus the optional bar icon.

<?php
    return [
        'title' => 'my_addon',
        'description' => 'my_addon_description',
        'version' => '1.0.0',
        'requires_core' => '>=1.1.0',
        'toolbar' => [
            'local' => true,   // show inside the local section of the tools dropdown
            'global' => false, // or true if the addon is locale-independent
            'bar' => true      // add a custom icon on the toolbar itself
        ]
    ];
    

Once toolbar.local or toolbar.global is set to true, the dropdown automatically displays your addon's icon and name (pulled from /core/addons/<addon>/icon.png). No extra markup is required.

Local vs Global Tools

  • Use toolbar.local when the addon stores locale-specific data. Horuph will show a small locale badge next to the icon so users know which site variant they are about to edit.
  • Use toolbar.global for addons whose data is shared across every locale.
  • Do not enable both flags at once; pick the scope that matches your addon.
  • The dropdown placement respects user permissions and disabled-toolbar settings, so ensure your addon defines a console access permission (often <addon>_console_access) when you expose dropdown tools.

Bar Icon Requirements

If toolbar.bar is true, you must create core/addons/<addon>/views/toolbar/icon_bar.php. Horuph includes this file directly on the toolbar whenever the addon is enabled, the user has permission, and the icon is not disabled in global settings.

  • Wrap your output with permission checks so unauthorized users never see the button.
  • Use the admin-toolbar-button class so the icon inherits the default styling.
  • You control everything about the icon's markup and behavior (Winbox window, link, script, etc.).
  • Keep IDs unique (for example, atb_my_addon) to avoid clashing with other addons.

Example icon_bar.php

<?php if (hp_verify_permission('my_addon_console_access')) { ?>
    <a
        id="atb_my_addon"
        class="admin-toolbar-button"
        title="<?php echo hp_t('my_addon', 'my_addon'); ?>"
        onclick="newWinbox({ title: '<i class=\'bi bi-lightning\'></i>&nbsp; <?php echo hp_t('my_addon', 'my_addon'); ?>', width: '480px', height: '100%', url: '/' + SITE_DATA.urlLocale + '/my_addon/window/panel' }); return false;">
        <img src="/core/addons/my_addon/icon.png" alt="">
    </a>
    <?php } ?>
    

Tip: keep your icon_bar.php small and permission-gated so the toolbar stays fast.

Workflow

  1. Decide whether your addon's tools belong in the local dropdown, the global dropdown, the bar, or a combination (maximum one dropdown slot plus one bar icon).
  2. Update manifest.php with the appropriate toolbar flags.
  3. If you enabled toolbar.bar, create views/toolbar/icon_bar.php and render your custom button.
  4. Refresh the admin toolbar and verify that the icon appears in the correct section, respects permissions, and opens the expected window or link.
  5. Use the Global Settings → Toolbar page to confirm the icon can be toggled like other toolbar items.

Design Reference

Match the visual style (spacing, icon sizing, hover states) from the Toolbar Icon Bar Design Guide whenever you customize the bar button markup.

Addon Notification Counts

The admin UI can show live notification badges for every addon across the toolbar, the tools dropdown, and even on the browser favicon. To hook into this system, each addon can expose a helper function that reports how many unread items it currently has.

How the Notification System Works

  • core/views/endpoints/notifications-badges.php loops through every installed addon and looks for a helper named [addon]_notifications_count(). When it finds one, it stores the returned number and caches the result for 15 seconds in the session.
  • public/assets/js/global-admin.js calls the endpoint every 15 seconds. It refreshes toolbar badges, the "addons" total badge, and toggles the blinking favicon when there are unread items.
  • Any badge element with a class such as .my_addon-badge or the global .addons-total-badge gets updated automatically, so you only need to provide the helper.

Where to Place the Helper

Add the helper to your addon's support/helpers.php file (see Addon Helpers for details), or create that file if it does not exist. Helpers in this file are autoloaded for every request, so the endpoint can call them without manual includes.

core/
    addons/
        my_addon/
        support/
            helpers.php   <!-- define my_addon_notifications_count() here -->
        ...

Create [addon]_notifications_count()

  1. Name the function exactly after your addon slug followed by _notifications_count (for example, my_addon_notifications_count()).
  2. Return an integer that represents unread items. Use a fast query or cached value to avoid slowing down the endpoint (it runs every 15 seconds for every admin).
  3. When the addon has no notifications, return 0. Avoid returning null or strings.
  4. Wrap database access in try/catch or defensive checks so a failing query does not break the endpoint.

Example Helper

<?php
    // core/addons/my_addon/support/helpers.php (the file is automatically loaded in bootstrap)
    function my_addon_notifications_count() {
        try {
            $result = hp_db_one(
                "SELECT COUNT(*) AS count FROM my_addon_items WHERE seen = 0",
                []
            );
            return (int) ($result['count'] ?? 0);
        } catch (Exception $e) {
            hp_log('my_addon_notifications_count failed', ['error' => $e->getMessage()], 'ERROR');
            return 0;
        }
    }
    

Tips

  • Keep helpers lightweight; heavy joins or large scans can make every admin navigation feel slow because the endpoint runs frequently.
  • If the addon tracks "seen" or "read" states, update those tables as normal - just be sure the helper queries only the unread subset.
  • When you remove the helper (or rename the addon), delete the old function so the endpoint does not attempt to call a stale name.
  • Remember that the badges cache lasts some seconds, so instantly clearing values may take one refresh cycle.

Addon Sitemaps

Each addon can publish sitemap sections that get merged into /sitemap.xml. This guide shows where to place the files and how to return URLs.

Folder Layout

core/
    addons/
        my_addon/
        sitemaps/
            sitemaps.php   (list of sitemap names)
            sitemap.php    (build URLs for a request)

sitemaps.php

This file returns an array of sitemap identifiers. Each entry becomes part of the URL /sitemaps/my_addon/<name>-<locale>.xml.

<?php
    return [
        'blog',
        'products'
    ];
    ?>

sitemap.php

This file runs every time a sitemap URL is requested. You get:

  • $locale – locale extracted from the URL.
  • $requestParts – array of the requested sitemap name split by - (without the locale part).

Return an array of URL definitions. Each item needs url, changefreq, and priority. lastmod is optional but recommended.

<?php
    $urls = [];

    if ($requestParts === ['blog']) {
        $urls[] = [
            'url' => hp_url($locale, ['blog']),
            'lastmod' => date('c'),
            'changefreq' => 'weekly',
            'priority' => 0.7
        ];

        // pull more rows from the database and append them the same way
    }

    return $urls;
    ?>

How to Build

  1. Create the sitemaps folder and both files inside your addon.
  2. List every sitemap name in sitemaps.php.
  3. Inside sitemap.php, check $requestParts to know which sitemap was requested and return the matching URLs.
  4. Use hp_url($locale, ...) to build localized links.

Example Output

Requesting /sitemaps/my_addon/blog-fa_ir.xml will run your sitemap.php with $requestParts = ['blog'] and $locale = 'fa_ir'.

Tips

  • Check feature flags before returning URLs (example: skip the sitemap if your addon’s public list is disabled).
  • Keep sitemap names simple (letters, numbers, hyphens).
  • If you expect thousands of links, split them by date, alphabet, or any rule by creating multiple entries in sitemaps.php (e.g., content-a-m and content-n-z).
  • Always verify the locale exists before generating URLs.
  • Wrap database calls in try/catch so one failure does not break the sitemap.

Addon Queue Worker Handlers

Addons can run background work by defining job handler functions inside core/addons/<addon>/workers/handlers.php. The queue worker loads these files and executes your handler when a matching job is picked from system_queue_jobs.

Folder Layout

core/
    addons/
        my_addon/
        workers/
            handlers.php

Job Type Format

When you enqueue a job, set job_type to a comma-separated string:

  • my_addon,my_job - runs the PHP function my_job($payload).

Important: if you enqueue a job type without a comma (example: my_job), it is treated as a core job and will not call any addon handlers when picked up.

How to Create a Handler

  1. Create core/addons/my_addon/workers/handlers.php.
  2. Define a global PHP function that accepts one argument: the payload array.
  3. Return an array with success (boolean) and message (string).
  4. Pick a unique function name to avoid collisions with other addons.

Handler Example

<?php
    // core/addons/my_addon/workers/handlers.php

    function my_addon_do_work(array $payload): array
    {
        if (empty($payload['user_id'])) {
            return [
                'success' => false,
                'message' => 'user_id is required'
            ];
        }

        // Do your background work...

        return [
            'success' => true,
            'message' => 'Done'
        ];
    }

Enqueue Example

Use hp_queue_job() from server-side code (controller, helper, etc):

<?php
    $jobId = hp_queue_job('my_addon,do_work', [
        'user_id' => 123
    ], [
        'queue' => 'default',
        'priority' => 0,
        'max_attempts' => 3
    ]);

What the Worker Expects

  • The payload is stored as JSON and decoded into an array before your function is called.
  • If your function throws an exception, the job is treated as failed.
  • If success is false, the worker retries until max_attempts is reached.

Example Job Type

Keep your job_type addon-scoped (first segment is your addon slug):

my_addon,do_work

Notes

Addon Bootstrap (Global Context)

If you want a value to be available everywhere (public pages, console pages, controllers/actions, endpoints, and workers), add it to your addon's bootstrap file. Addon bootstrap runs on every request and can extend the shared $siteContext array.

File Location

Create this file inside your addon:

core/addons/<addon_name>/views/bootstrap.php

The core loads it automatically if it exists.

When It Runs

  • Web requests: included from core/bootstrap.php after config, helpers, database, and auth are loaded.
  • Workers/jobs: included from core/workers/worker_bootstrap.php (note: workers do not build the full auth/url context).
  • Do not assume every $siteContext key exists in every runtime; always guard with isset() / !empty().

Important: Horuph only loads bootstrap files for addons that have a valid manifest.php file and are present in $siteContext['addons']. If an addon is missing or not installed, Horuph will not attempt to load its bootstrap file. This ensures that core continues to function even if all addons are removed.

How to Add Global Data

  1. Create core/addons/<addon_name>/views/bootstrap.php.
  2. Write to $siteContext using a namespaced key (recommended: your addon slug).
  3. Keep the file fast and side-effect free (no output, no redirects, no exit).

Recommended Pattern (Namespaced Keys)

<?php
    // core/addons/<addon_name>/views/bootstrap.php
    $siteContext['<addon_name>'] = $siteContext['<addon_name>'] ?? [];

    // Example: feature flags / environment values
    $siteContext['<addon_name>']['features'] = [
        'beta_ui' => (bool)Config::get('<addon_name>_beta_ui', false),
    ];
    

Safe Guards (Workers vs Web Requests)

Workers may not set auth-related keys. Use guards instead of reading keys directly.

<?php
    if (!empty($siteContext['authenticated']) && !empty($siteContext['user']['id'])) {
        // Load extra user-aware context safely
    }
    

Using What You Added

After bootstrap, anything you put in $siteContext is available in:

  • Controllers
  • Endpoints
  • Layouts
  • Windows
  • Blocks
  • Console tabs

Note: Shared files and API files have only partial $siteContext access. It's recommended to use them like plain PHP with helper functions rather than relying on full $siteContext.

Checking if Another Addon Exists

When your addon depends on data from another addon, always check if that addon exists first. Horuph has no hard connections between addons, so you must implement conditional logic:

<?php
    // Check if another addon is installed and loaded
    $otherAddonExists = false;
    foreach ($siteContext['addons'] ?? [] as $addon) {
        if ($addon['title'] === 'other_addon') {
            $otherAddonExists = true;
            break;
        }
    }

    // Use the addon data only if it exists
    if ($otherAddonExists && !empty($siteContext['other_addon']['data'])) {
        // Use data from other addon
    }
    

Or check for specific data in $siteContext:

<?php
    // In a controller/action or endpoint
    if (!empty($siteContext['<addon_name>']['features']['beta_ui'])) {
        // enable beta feature
    }
    

What Not to Do

  • Do not echo HTML/JSON, change headers, or redirect from addon bootstrap.
  • Do not run heavy database queries on every request; cache or gate them behind checks.
  • Do not overwrite core keys (like user, dictionary, urlData) unless you fully own that behavior.
  • Do not assume request-only data exists (URL segments, locale, session, auth) when running in workers.
  • Do not assume other addons exist: Always check if another addon is installed before accessing its data from $siteContext. Horuph has no hard connections between addons, so missing addons won't break your code if you check first.

Example: Loading Addon Environment

If your addon ships an environment.php, you can load it into a namespaced context key:

<?php
    // core/addons/my_addon/views/bootstrap.php
    $siteContext['my_addon'] = $siteContext['my_addon'] ?? [];
    $siteContext['my_addon']['env'] = require BASE_PATH . '/core/addons/my_addon/environment.php';
    

Part 6: Addon Advanced Topics

Custom Input Types

This guide explains how to create custom input field types in your addon. Custom input types allow you to add special field types that can be used in content forms and dynamic forms throughout Horuph.

Folder Layout

To create a custom input type, you need to add files to your addon's views folder:

core/
    addons/
        my_addon/
        views/
            input_fields.php
            input_mytype.php
            input_mytype_save.php
            input_mytype_view.php

How to Create

  1. Create input_fields.php to register your field type.
  2. Create input_[type].php to render the input in forms.
  3. Create input_[type]_save.php to process the value when saving.
  4. Create input_[type]_view.php to display the value in read-only mode.

Step 1: Register the Field Type

Create core/addons/my_addon/views/input_fields.php to register your custom field type:

<?php
    return [
        'addon' => 'my_addon',
        'type' => 'mytype',
        'use' => [
            'field_name',
            'field_label',
            'help',
            'required',
            'placeholder',
            // Add any custom properties your field needs
        ],
        'rules' => [
            'hide' => ['width', 'read_only', 'disabled', 'default_text'],
        ],
    ];

Properties:

  • addon - Your addon name (must match folder name)
  • type - The field type identifier (used in field definitions)
  • use - List of standard field properties your type supports. Available standard properties include: field_name, field_label, help, required, placeholder, default_text, height, width, prefix, suffix, read_only, disabled, validation_rules, min_length, max_length, and more.
  • rules.hide - List of properties to hide in the field editor (properties that don't apply to your field type)

Custom Properties: You can add custom properties to your field definition in the JSON. These will be available in your view files via $addonField['property_name']. For example, if you add "custom_config": "value" to your field JSON, you can access it as $addonField['custom_config'] in your input view files.

Step 2: Create Input View

Create core/addons/my_addon/views/input_mytype.php to render the input field in forms:

<?php
    // Custom input view for 'mytype' field
    // Available variables:
    // $addonFieldName - Field name (string)
    // $addonFieldLabel - Field label (string)
    // $addonFieldValue - Current value (for edit mode)
    // $addonFieldRequired - Boolean, if field is required
    // $addonFieldReadOnly - Boolean, if field is read-only
    // $addonFieldDisabled - Boolean, if field is disabled
    // $addonFieldHelp - Help text (string)
    // $addonFieldPlaceholder - Placeholder text (string)
    // $addonFieldDefault - Default value (string)
    // $addonField - Full field definition array (all properties from field JSON)
    // $siteContext - Global site context (available in all views)
    ?>
    <input type="text" 
        class="form-control" 
        name="<?php echo htmlspecialchars($addonFieldName); ?>" 
        id="field_<?php echo htmlspecialchars($addonFieldName); ?>" 
        value="<?php echo htmlspecialchars($addonFieldValue); ?>" 
        placeholder="<?php echo htmlspecialchars($addonFieldPlaceholder); ?>"
        <?php echo $addonFieldRequired ? 'required' : ''; ?> 
        <?php echo $addonFieldReadOnly ? 'readonly' : ''; ?>
        <?php echo $addonFieldDisabled ? 'disabled' : ''; ?>>

This file is included when rendering forms. You can use any HTML and JavaScript needed for your custom input.

Note: Help text is automatically displayed after your input if $addonFieldHelp is set. You don't need to output it yourself.

JavaScript and CSS: You can include <script> and <style> tags directly in this file. They will be included in the page when the form is rendered.

Step 3: Create Save Processor

Create core/addons/my_addon/views/input_mytype_save.php to process the value when saving:

<?php
    // Save processor for 'mytype' field
    // Available variables:
    // $saveFieldName - Field name (string)
    // $saveFieldValue - Submitted value (string or array)
    // $saveFieldType - Field type ('mytype')
    // $saveFieldDef - Full field definition array (all properties from field JSON)
    // $siteContext - Global site context (available in all views)

    // Process and return the value
    // You can modify $saveFieldValue or return a new value
    $processedValue = trim($saveFieldValue);
    // Do any custom processing, validation, or transformation here

    // Return the processed value
    return $processedValue;
    ?>

Important: This file should return the processed value. If you modify $saveFieldValue directly, return it at the end. If the file returns 1 or true, the system will use the modified $saveFieldValue variable.

Validation: You can validate the value here and return a default or sanitized value if validation fails. For user-facing validation errors, use HTML5 validation attributes in the input view file.

Step 4: Create View Display

Create core/addons/my_addon/views/input_mytype_view.php to display the value in read-only mode:

<?php
    // View display for 'mytype' field (read-only)
    // Available variables:
    // $addonFieldName - Field name (string)
    // $addonFieldLabel - Field label (string)
    // $addonFieldValue - Value to display (string or array)
    // $addonField - Full field definition array (all properties from field JSON)
    // $siteContext - Global site context (available in all views)

    if (empty($addonFieldValue)) {
        echo '<span class="text-muted">No value</span>';
    } else {
        echo htmlspecialchars($addonFieldValue);
    }
    ?>

This file is used when displaying field values in content version views, form submission details, and email templates.

Email Mode: When used in email templates, the variable $emailMode is set to true and $baseUrl contains the site's base URL. Use these to generate absolute URLs for images or links.

JavaScript Initialization

If your custom input requires JavaScript initialization (like a date picker or signature pad), you can include it directly in your input view file:

<?php
    // Custom input with JavaScript
    ?>
    <input type="text" 
        class="my-custom-input" 
        name="<?php echo htmlspecialchars($addonFieldName); ?>" 
        id="field_<?php echo htmlspecialchars($addonFieldName); ?>" 
        value="<?php echo htmlspecialchars($addonFieldValue); ?>" 
        data-config="<?php echo htmlspecialchars(json_encode($addonField['custom_config'] ?? [])); ?>">

    <script>
    (function() {
        const input = document.getElementById('field_<?php echo htmlspecialchars($addonFieldName); ?>');
        if (input) {
            // Initialize your custom input here
            // Use data attributes to pass configuration
            const config = JSON.parse(input.getAttribute('data-config') || '{}');
            // Your initialization code...
        }
    })();
    </script>

Note: Wrap your JavaScript in an IIFE (Immediately Invoked Function Expression) to avoid variable conflicts when the form is loaded multiple times.

HTML5 Validation

You can add HTML5 validation attributes to your input for client-side validation:

<input type="text" 
        name="<?php echo htmlspecialchars($addonFieldName); ?>" 
        pattern="[A-Z0-9]+"
        title="Only uppercase letters and numbers allowed"
        <?php echo $addonFieldRequired ? 'required' : ''; ?>>

For complex validation, use the pattern attribute with a regex. Remember that HTML5 patterns use JavaScript regex syntax, not PHP regex.

Example: Complete Custom Input Type

Here's a complete example for a "color picker" input type:

input_fields.php

<?php
    return [
        'addon' => 'my_addon',
        'type' => 'color',
        'use' => [
            'field_name',
            'field_label',
            'help',
            'required',
            'default_text',
        ],
        'rules' => [
            'hide' => ['width', 'read_only', 'disabled', 'prefix', 'suffix'],
        ],
    ];

input_color.php

<?php
    ?>
    <input type="color" 
        class="form-control form-control-color" 
        name="<?php echo htmlspecialchars($addonFieldName); ?>" 
        id="field_<?php echo htmlspecialchars($addonFieldName); ?>" 
        value="<?php echo htmlspecialchars($addonFieldValue ?: '#000000'); ?>" 
        <?php echo $addonFieldRequired ? 'required' : ''; ?> 
        <?php echo $addonFieldDisabled ? 'disabled' : ''; ?>>

input_color_save.php

<?php
    // Validate color format (hex color)
    $color = trim($saveFieldValue);
    if (preg_match('/^#[0-9A-Fa-f]{6}$/', $color)) {
        return strtoupper($color);
    }
    return '#000000'; // Default if invalid
    ?>

input_color_view.php

<?php
    if (empty($addonFieldValue)) {
        echo '<span class="text-muted">No color selected</span>';
    } else {
        echo '<div style="display: inline-block; width: 30px; height: 30px; background-color: ' . htmlspecialchars($addonFieldValue) . '; border: 1px solid #ddd; border-radius: 4px;"></div> ';
        echo '<code>' . htmlspecialchars($addonFieldValue) . '</code>';
    }
    ?>

Where Custom Input Types Work

Once created, your custom input type will automatically work in:

  • Content type field editors (when creating/editing content types)
  • Content forms (when creating/editing content)
  • Dynamic forms (when creating/editing form templates)
  • Form submissions (when viewing submitted data)
  • Content version views (when viewing content history)
  • Email templates (when sending form submission emails)

Available Variables Summary

Here's a complete list of variables available in each file type:

input_[type].php (Input View)

  • $addonFieldName - Field name
  • $addonFieldLabel - Field label
  • $addonFieldValue - Current value (for edit mode)
  • $addonFieldRequired - Boolean, if required
  • $addonFieldReadOnly - Boolean, if read-only
  • $addonFieldDisabled - Boolean, if disabled
  • $addonFieldHelp - Help text
  • $addonFieldPlaceholder - Placeholder text
  • $addonFieldDefault - Default value
  • $addonField - Full field definition array
  • $siteContext - Global site context

input_[type]_save.php (Save Processor)

  • $saveFieldName - Field name
  • $saveFieldValue - Submitted value
  • $saveFieldType - Field type
  • $saveFieldDef - Full field definition array
  • $siteContext - Global site context

input_[type]_view.php (View Display)

  • $addonFieldName - Field name
  • $addonFieldLabel - Field label
  • $addonFieldValue - Value to display
  • $addonField - Full field definition array
  • $siteContext - Global site context
  • $emailMode - Boolean, true when rendering in email (optional)
  • $baseUrl - Site base URL (when $emailMode is true)

Tips

  • Use descriptive type names that don't conflict with built-in types (text, email, number, etc.)
  • Always validate and sanitize values in the save processor
  • Provide fallback displays in view files for empty or invalid values
  • You can include JavaScript and CSS in your input view file if needed
  • Test your custom input type in all contexts (content forms, dynamic forms, etc.)
  • Access custom field properties via $addonField['property_name'] in any view file
  • For complex inputs, use data attributes to pass configuration to JavaScript
  • Remember to escape all output using htmlspecialchars() to prevent XSS attacks

Local Settings Inputs (Addon)

Addons can extend the Console → Settings → Local Settings form by adding their own input fields and save logic. This is useful when an addon needs to store extra per-locale configuration values.

Folder Layout

core/
    addons/
        my_addon/
        views/
            console/
            locale_settings/
                inputs.php
                inputs_save.php

What These Files Do

  • inputs.php: renders extra HTML fields inside the Local Settings form.
  • inputs_save.php: runs during /action/settings-local-save and must add your values into the $config array before it is saved.

The saved values are written into public/languages/{locale}/config.php.

How to Create

  1. Create core/addons/my_addon/views/console/locale_settings/.
  2. Add inputs.php and render your fields using normal Bootstrap markup.
  3. Add inputs_save.php, read submitted values from $data, sanitize them, and write them into $config.
  4. Use the saved value in your addon (for example with Config::get('my_key')).

Create inputs.php

Local settings loads the current config into $configData. Use it to show the current value (with a safe default).

// core/addons/my_addon/views/console/locale_settings/inputs.php
    <div class="row align-items-center p-2">
        <label class="col-12 col-md-3 mb-1 mb-md-0 small form-item-label">
            <?php echo hp_t('my_setting_label', 'my_addon'); ?>:
        </label>
        <div class="col-12 col-md-9">
            <input
                type="text"
                class="form-control form-control-sm"
                name="my_addon_my_setting"
                value="<?php echo htmlspecialchars($configData['my_addon_my_setting'] ?? '', ENT_QUOTES, 'UTF-8'); ?>">
        </div>
    </div>

Create inputs_save.php

This file is included during save. Do not output anything; only update $config. Always sanitize input.

// core/addons/my_addon/views/console/locale_settings/inputs_save.php
    <?php
    if (isset($data['my_addon_my_setting'])) {
        $config['my_addon_my_setting'] = hp_sanitize($data['my_addon_my_setting']);
    }

Tips

  • Use unique config keys (preferably prefixed with your addon name) to avoid collisions with core settings.
  • Local settings are per-locale (saved under public/languages/{locale}/config.php).
  • If your input is a number or boolean, cast it after sanitizing (example: (int)).

Design Reference

Follow the form layout and styling patterns from the Console Settings Design Guide when building your input fields. That guide covers row structure, label placement, and responsive behavior.

Password Settings (Addon)

Addons can change the password rules used by the Change Password page (and any other place that chooses to reuse the same settings). This lets you control things like minimum length and complexity rules.

Folder Layout

core/
    addons/
        my_addon/
        support/
            password_settings.php

How It Works

When Horuph renders the change password page and when it validates a password change request, it checks installed addons for support/password_settings.php and reads the returned settings.

These files are used by:

  • core/views/layouts/auth/password.php (client-side validation + UI hints)
  • core/controllers/actions/password-change.php (server-side validation)

How to Create

  1. Create core/addons/my_addon/support/password_settings.php.
  2. Return an array with your password rules.
  3. Test by opening the change password page and submitting a new password.

Returned Settings

  • passwordMinLength (int): minimum password length.
  • passwordComplexity (0/1): enable regex complexity validation.
  • passwordValidationRegex (string): regex source string used for validation (without /.../ delimiters).

If passwordComplexity is enabled, the password must match passwordValidationRegex.

Example

// core/addons/my_addon/support/password_settings.php
    <?php
    return [
        'passwordMinLength' => 10,
        'passwordComplexity' => 1,
        // At least: 1 lowercase, 1 uppercase, 1 number, 1 special, no spaces
        'passwordValidationRegex' => '^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[^A-Za-z0-9\\s])\\S+$',
    ];

Notes

  • If multiple addons provide support/password_settings.php, the last one detected wins.
  • Always enforce rules server-side (Horuph does this in password-change.php).
  • To reuse the same rules on registration, read the same settings file(s) in your registration controller and validate the password the same way.

Verify Permission (Permission Manager Addon)

Horuph permission checks are done through hp_verify_permission('permission_key'). If you want an addon to control how permissions are verified, you can create a permission manager addon.

What This Does

  • When an addon has support/verify_permission.php, Horuph treats it as a permission manager.
  • Whenever code calls hp_verify_permission(...), Horuph includes that file to decide if the current user has the permission.
  • Your file must return true or false.

Folder Layout

core/
    addons/
        my_addon/
        support/
            verify_permission.php

How to Create

  1. Create core/addons/my_addon/support/verify_permission.php.
  2. Write your permission logic using $permission and $siteContext.
  3. Return a boolean.
  4. Use hp_verify_permission('some_permission') anywhere you need permission checks.

Inputs Available

  • $permission: the permission key passed into hp_verify_permission().
  • $siteContext: current request context (user, roles, permissions, locale, etc.).

Example

This example simply checks the user permission list stored in $siteContext['user']['permissions']:

// core/addons/my_addon/support/verify_permission.php
    <?php
    return in_array($permission, $siteContext['user']['permissions']);

Notes

  • Owners automatically pass permission checks (see hp_verify_permission() in core/classes/Supports.php).
  • Only one addon can be the permission manager at a time. If multiple addons include support/verify_permission.php, the last one detected during bootstrap wins.
  • If you want admins to assign permissions to roles, also define your permission keys in the relevant console permission files (for example, in an addon console _permissions.php).

Part 7: Addon Services & Integrations

users-and-permissions

mail-service

The mail service addon provides a queue-based email system and a helper (add_email()) that other addons call to send mail. It stores messages in mail_service_queue and logs results in mail_service_log.

Send Email (Helper)

Use add_email($emailData) from server-side code. Minimum required fields are send_from, to_email, and subject:

<?php
    add_email([
        'send_from' => 'newsletter/campaign/123/456',
        'to_email' => 'user@example.com',
        'subject' => 'Welcome',
        'body_html' => '<p>Hello</p>',
        'priority' => 0
    ]);
    

The helper inserts a row in mail_service_queue and enqueues a job with hp_queue_job('mail_service,send_mail', ...).

Queue Worker

The mail service queue worker runs mail_service_send_mail($payload) from:

core/addons/mail_service/workers/handlers.php

It processes queued emails, updates status (pending/sending/sent/failed), and writes to mail_service_log.

Mail Service Handlers

Define mail callbacks in:

core/
    addons/
        my_addon/
        workers/
            mail_handlers.php

Handler Naming

Set send_from when you call add_email() using this pattern:

my_addon/action/arg1/arg2

The mail service calls a matching handler function named:

my_addon_action_mail_handler($value, $response)

Where:

  • $value is an array of the remaining path parts (example: ['arg1', 'arg2']).
  • $response is the mail service result (example keys: email_id, success, message).

Example (Newsletter)

<?php
    // send_from: newsletter/campaign/123/456
    function newsletter_campaign_mail_handler($value, $response) {
        $campaignId = isset($value[0]) ? (int)$value[0] : 0;
        $subscriberId = isset($value[1]) ? (int)$value[1] : 0;
        $emailId = isset($response['email_id']) ? (int)$response['email_id'] : null;

        if ($emailId === null || $emailId <= 0) {
            return;
        }

        // update newsletter tables...
    }
    

Notes

  • Use these handlers for delivery status updates, bounces, and post-send bookkeeping.
  • This is separate from queue workers (workers/handlers.php).
  • The mail service worker loads every addon's workers/mail_handlers.php before sending.

themes

WebSockets Addon

This guide explains how to use the WebSockets addon to enable real-time communication in your application. You will learn how to install it, configure allowed topics, and connect from your frontend pages.

Installing the Addon

First, install the websockets addon in your system. The addon provides a WebSocket server that clients can connect to for real-time messaging.

  1. Make sure the websockets addon is installed in core/addons/websockets/
  2. Configure the WebSocket server settings in the console (Settings page)
  3. Set the WS_SECRET environment variable for token security
  4. Start the WebSocket server by running php core/addons/websockets/bin/server.php

Configuring Allowed Topics

If you use the allowlist policy, you need to define which topics are allowed. Create an allow list file in your addon folder.

Folder Layout

core/
    addons/
        my_addon/
        websockets/
            allow_list.php

How to Create

  1. Create a folder named websockets inside your addon folder
  2. Create a file named allow_list.php inside the websockets folder
  3. Return an array of allowed topic names or patterns

Example

<?php
    return [
        'notifications',
        'chat.room.123',
        'user.*',  // Allows user.1, user.2, user.123, etc.
        'orders.*.updates'
    ];

The allow list supports exact topic names and prefix patterns. A pattern ending with .* matches all topics starting with that prefix. For example, user.* matches user.1, user.123, and user.456.notifications.

Connecting from Frontend

To enable WebSocket connections on a page, add a script tag that sets the required global variables before the page loads the WebSocket client script.

Required Variables

  • window.WS_ENABLED - Must be true to enable connection
  • window.WS_TOPIC - The topic name to subscribe to (string)

Optional Variables

  • window.WS_HOST - WebSocket server host (default: current page hostname)
  • window.WS_PORT - WebSocket server port (default: 8080)
  • window.WS_TOKEN_ENDPOINT - Token endpoint URL (default: /endpoints/websockets/token)
  • window.WS_PROTOCOL - Protocol to use: ws: or wss: (default: based on page protocol)

Example

<script>
    window.WS_ENABLED = true;
    window.WS_TOPIC = "notifications";
    </script>
    <script src="/core/addons/websockets/assets/js/globals.js"></script>

The script will automatically connect to the WebSocket server, get an authentication token, and subscribe to the specified topic.

Listening to Messages

Listen for WebSocket messages using DOM events:

<script>
    window.addEventListener('ws:message', function(event) {
        var data = event.detail;
        console.log('Topic:', data.topic);
        console.log('Message:', data.message);
        console.log('Timestamp:', data.timestamp);
    });

    window.addEventListener('ws:subscribed', function(event) {
        console.log('Subscribed to:', event.detail.topic);
    });

    window.addEventListener('ws:error', function(event) {
        console.error('WebSocket error:', event.detail.message);
    });
    </script>

Using WebSocket in Your Addon

Here's a complete example of how to create an addon that uses WebSockets for real-time features.

Step 1: Create Allow List

Create core/addons/my_addon/websockets/allow_list.php:

<?php
    return [
        'my_addon.notifications',
        'my_addon.chat.*',
        'my_addon.user.*'
    ];

Step 2: Add WebSocket Script to Your Page

In your addon view file, add the WebSocket connection script:

<script>
    window.WS_ENABLED = true;
    window.WS_TOPIC = "my_addon.notifications";
    </script>
    <script src="/core/addons/websockets/assets/js/globals.js"></script>

    <script>
    window.addEventListener('ws:message', function(event) {
        if (event.detail.topic === 'my_addon.notifications') {
        // Handle notification message
        showNotification(event.detail.message);
        }
    });
    </script>

Step 3: Publish Messages from Backend

To send messages to connected clients, use the HTTP publish endpoint:

<?php
    $publishUrl = 'http://127.0.0.1:8081/publish';
    $secret = getenv('WS_SECRET');

    $data = [
        'topic' => 'my_addon.notifications',
        'message' => [
            'type' => 'alert',
            'text' => 'New notification!'
        ]
    ];

    $ch = curl_init($publishUrl);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_POST, true);
    curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
    curl_setopt($ch, CURLOPT_HTTPHEADER, [
        'Content-Type: application/json',
        'X-WS-SECRET: ' . $secret
    ]);

    $response = curl_exec($ch);
    curl_close($ch);

Tips

  • Always use the allow list when possible. It's more secure than allowing all topics.
  • Use prefix patterns like user.* to allow multiple related topics without listing each one.
  • The WebSocket client automatically reconnects if the connection is lost.
  • Make sure the WebSocket server is running before clients try to connect.
  • Use wss: protocol (secure WebSocket) when your page uses HTTPS.
  • Topic names must match the regex pattern defined in settings (default: alphanumeric with dots, colons, dashes, and underscores).

Available Events

The WebSocket client emits these DOM events:

  • ws:subscribed - Fired when subscription is confirmed. Detail: {topic, status}
  • ws:unsubscribed - Fired when unsubscription is confirmed. Detail: {topic}
  • ws:message - Fired when a message is received. Detail: {topic, message, timestamp}
  • ws:error - Fired when an error occurs. Detail: {message}

Public API

The WebSocket client exposes a public API through window.WebSocketClient:

  • WebSocketClient.connect() - Manually connect to the server
  • WebSocketClient.disconnect() - Disconnect from the server
  • WebSocketClient.subscribe(topic) - Subscribe to a new topic
  • WebSocketClient.unsubscribe(topic) - Unsubscribe from a topic
  • WebSocketClient.publish(message) - Publish a message to the current topic
  • WebSocketClient.isConnected() - Check if currently connected
  • WebSocketClient.getTopic() - Get the current topic name

Part 8: Guidelines

Writing Structure Help Documentation

This guide explains how to write help documentation for development structures. Follow these simple rules to create clear and helpful guides.

Basic Rules

  • Be concise: Explain everything you need to, but keep it short. Cover all capabilities and methods without extra words.
  • Keep it simple: Write so a 14-year-old can understand. Avoid technical jargon and scary terms.
  • Focus on "how to create": Explain how to build something, not how it works internally.
  • Separate core and addons: There are only two areas: core (anything that is not inside an addon) and addons. Do not invent new categories such as "core addons"; clearly state which area a guide covers.
  • No built-in addons: Horuph does not ship built-in addons. Anything inside core/addons/ is an addon. Help docs must be addon-agnostic and should not rely on any addon folder being present.
  • Use examples: When helpful, add short and simple examples. Examples are the most important part.
  • Prefer addon-agnostic examples: Use placeholder names such as my_addon so the guide works everywhere. Do not reference real addon names in docs.
  • Write in English: All help documentation should be in English.
  • Use default HTML: Don't add custom styling. Use basic HTML tags unless needed for code display.

Document Structure

Start with a clear title using <h1>. Add a short introduction paragraph that tells the reader what they will learn.

<h1>Your Guide Title</h1>
    <p>Brief explanation of what this guide covers and why it's useful.</p>

Organize with Headings

Use <h2> for main sections and <h3> for subsections. Each section should cover one topic clearly.

<h2>Creating a File</h2>
    <p>How to create the file...</p>

    <h3>File Location</h3>
    <p>Where to put it...</p>

Show Folder Structure

When explaining file organization, use <pre> to show folder layouts. Keep it simple and clear. Remember that anything outside addons/ is part of the core.

<h2>Folder Layout</h2>
    <pre>core/
    addons/
        my_addon/
        languages/
            en_us.json</pre>

Provide Step-by-Step Instructions

Use ordered lists <ol> for steps that must be done in order. Use unordered lists <ul> for tips or optional items.

<h2>How to Create</h2>
    <ol>
        <li>First step description.</li>
        <li>Second step description.</li>
        <li>Third step description.</li>
    </ol>

Include Code Examples

Show code examples using <pre> tags. For inline code like function names, use <code>. Always make examples simple and complete.

<p>Call the function like this: <code>myFunction()</code> to get the result.</p>
    <pre>{
        "key": "value",
        "another_key": "another value"
    }</pre>

What to Include

  • What files or folders are needed
  • Where to put them
  • What content goes inside
  • How to use what you created
  • Simple examples showing the result

What to Avoid

  • Long explanations about how things work internally
  • Technical details that aren't needed to create the structure
  • Complex examples with many options
  • Custom CSS or JavaScript
  • Advanced topics that confuse beginners
  • Relying on a real addon being installed. Always use generic examples (e.g., my_addon).

Example Structure

<h1>Guide Title</h1>
    <p>Brief intro.</p>

    <h2>Folder Layout</h2>
    <pre>path/to/files</pre>

    <h2>How to Create</h2>
    <ol>
        <li>Step 1.</li>
        <li>Step 2.</li>
    </ol>

    <h2>Example</h2>
    <pre>example code</pre>

    <h2>Tips</h2>
    <ul>
        <li>Tip 1.</li>
        <li>Tip 2.</li>
    </ul>

WindowBox Form Design Guide

Use this guide for important actions that need a focused modal (for example: reschedule job, change priority, pause job). Standard create/edit forms can use the regular console styles.

Two Layouts

  • Bottom toolbar: use when the action saves data.
  • Top toolbar: use for quick actions that do not save data (for example: print).

Layout 1: Bottom Toolbar (Save Actions)

<div class="h-100 d-flex flex-column">
    <div class="flex-grow-1 p-3">
        <!-- Form content -->
    </div>
    <div class="bg-light border-top p-3 flex-shrink-0 position-sticky bottom-0">
        <div class="d-flex align-items-center w-100 gap-2">
        <div class="flex-grow-1"></div>
        <button class="btn btn-primary px-4" type="submit">Save</button>
        </div>
    </div>
    </div>

Layout 2: Top Toolbar (Quick Actions)

<div class="h-100 d-flex flex-column">
    <div class="bg-light border-bottom p-3 flex-shrink-0 position-sticky top-0">
        <div class="d-flex align-items-center w-100 gap-2">
        <div class="flex-grow-1"></div>
        <button class="btn btn-primary px-4" type="submit">Run</button>
        </div>
    </div>
    <div class="flex-grow-1 p-3">
        <!-- Form content -->
    </div>
    </div>

Rules

  • Do not add a cancel button; the window already has a close (X).
  • The submit button should be the last item in the toolbar.
  • Use h-100 + d-flex flex-column on the wrapper so toolbars stay sticky.
  • Use the system datepicker for date/time fields (h-datetime-picker with data attributes).
  • Always include a return parameter when redirecting after submit.

Toolbar Icon Bar Design Guide

This guide shows how to add a toolbar bar icon for an addon.

File Location

core/addons/<addon>/views/toolbar/icon_bar.php

Manifest Flag

"toolbar" => [
    "bar" => true
    ]

Required Structure

<?php if (hp_verify_permission('<addon>_console_access')) { ?>
    <a id="atb_<addon>" class="admin-toolbar-button" title="<?php echo hp_t('<addon>', '<addon>'); ?>"
    onclick="newWinbox({ title: '<?php echo hp_t('<addon>', '<addon>'); ?>', width: '420px', url: '/' + SITE_DATA.urlLocale + '/<addon>/window/panel' }); return false;">
    <img src="/core/addons/<addon>/icon.png" alt="">
    </a>
    <?php } ?>

Rules

  • Always check permissions with hp_verify_permission().
  • Use ID format: atb_<addon>.
  • Use admin-toolbar-button class for styling.
  • Use SITE_DATA.urlLocale in the window URL.
  • End onclick with return false; to prevent navigation.

Dashboard Card Design Guide

Dashboard cards appear on the first row of the admin dashboard. They show quick stats and shortcuts.

File Location

core/addons/<addon>/views/dashboard/default.php

Optional data loader:

core/addons/<addon>/views/dashboard/data.php

Structure

  • Row 1: title + optional status indicator.
  • Row 2: big number(s) or key metric.
  • Row 3: small links or secondary stats.

Example

<div class="dashboard-card">
    <div class="dashboard-card-row">
        <strong>Mail Queue</strong>
        <span class="status-badge ok">Running</span>
    </div>
    <div class="dashboard-card-row">
        <div class="dashboard-card-value">42</div>
    </div>
    <div class="dashboard-card-row">
        <a href="#" onclick="windowBox('/' + SITE_DATA.urlLocale + '/addon-console/mail_service'); return false;">Open</a>
    </div>
    </div>

Tips

  • Keep queries light; dashboard loads often.
  • Use the provided design guide styles for spacing.
  • If you need live data, fetch via an endpoint.

Console Tab Toolbar Design Guide

Use a consistent toolbar above console tabs to host actions, filters, and search.

Layout Order

  • Actions (left)
  • flex-grow-1 spacer
  • Clear Filters button
  • Filters
  • Search input (right)

Rules

  • Use flex-nowrap so toolbar items do not wrap.
  • Do not use btn-sm or form-select-sm in toolbars.
  • Wrap button labels with <span class="d-none d-md-inline"> for mobile.
  • Use unique IDs/JS variables for tabs (all tabs load together).

Example

<div class="d-flex align-items-center w-100 flex-nowrap">
    <button class="btn btn-primary">
        <i class="bi bi-plus"></i> <span class="d-none d-md-inline">Add</span>
    </button>
    <div class="flex-grow-1"></div>
    <button class="btn btn-outline-secondary">Clear</button>
    <select class="form-select" style="max-width: 180px;">...</select>
    <input class="form-control" style="max-width: 260px; min-width: 150px;" placeholder="Search">
    </div>

Console Settings Design Guide

Use this layout for settings pages in addon consoles.

Row Structure

<div class="row mb-3">
    <div class="col-12 col-md-3">
        <label class="form-label">Label</label>
    </div>
    <div class="col-12 col-md-9">
        <input class="form-control" name="field">
        <small class="text-muted">Hint text</small>
    </div>
    </div>

Rules

  • Always translate labels with hp_t().
  • For passwords, keep inputs empty and only update if a new value is provided.
  • Use ?? for default values.
  • For important fields, consider input protector.
  • All save actions must pass permission checks.

Saving

  • Use environment.php for addon settings.
  • Use controller actions to validate and save.
  • Return success, message, and optional redirect.

Console List with Pagination Design Guide

Use this pattern for lists with search, filters, and pagination.

Page Structure

  • Toolbar (filters + search + actions)
  • Content area (table or cards)
  • Pagination footer

State Variables

let itemsPage = 1;
    let itemsPerPage = 50;
    let itemsFilters = { status: '', search: '' };

Rules

  • Always use debounce for search (example: 500ms).
  • Reset page to 1 when filters change.
  • Use ConsolePagination from public/assets/js/console-pagination.js.
  • Use escapeHtml() from public/assets/js/global.js for user data.
  • Keep IDs and JS variables unique per tab.

Endpoint Contract

{
    "success": true,
    "data": [...],
    "total": 120,
    "page": 1,
    "per_page": 50,
    "total_pages": 3
    }

Toolbar Tips

  • Use flex-nowrap to avoid wrapping.
  • Avoid btn-sm and form-select-sm.
  • Make search responsive: max-width: 300px; min-width: 150px;

Part 9: Core Server-side

Part 9: Core Server-side

Bootstrap Context (Server Side)

core/bootstrap.php runs on every request and prepares a shared $siteContext array. As a developer, you typically use bootstrap outputs to build URLs, detect locale and direction, read configuration flags, show user-aware UI, and load addon metadata.

Addon Independence: Horuph core has no hard connections to addons. Core continues to function normally even if all addons are removed. Addons are loaded conditionally - only addons with valid manifest.php files are included in $siteContext['addons'], and only those addons' bootstrap files are loaded. This modular design ensures core stability and allows addons to be added or removed without affecting core functionality.

Availability Across Contexts

$siteContext is fully available in these contexts:

  • Controllers - All $siteContext data is available
  • Endpoints - Full $siteContext access
  • Layouts - Complete $siteContext available
  • Windows - Full $siteContext access
  • Blocks - Complete $siteContext available
  • Console tabs - All $siteContext data accessible

Note: In shared files and api files, $siteContext is only partially available. It's better to treat them like plain PHP files with access to helper functions (database, etc.) rather than relying on full $siteContext.

What You Can Use

Bootstrap exposes practical values (not just internals). Use the links below to learn what each group is for and how to use it.

Quick Example

<?php
    // These values are ready after bootstrap
    $locale = $siteContext['locale'];
    $urlData = $siteContext['urlData'];

    // Build links using hp_url + urlData
    $home = hp_url($locale, []);
    $link = hp_url($locale, ['blog', 'post-123']);

    // Gate UI by login state
    if (!empty($siteContext['authenticated'])) {
        $name = $siteContext['user']['fullname'] ?? $siteContext['user']['username'];
    }
    

Related Docs

  • urlData - Full explanation of urlData vs urlPath and how URL mapping changes visible URLs.
  • hp_url - URL generator; the urls addon can override paths via route hooks.
  • Hooks - How addons register listeners and extend core without edits.

Bootstrap: Addons and Extension Points

Bootstrap loads addon manifests and exposes them in $siteContext['addons']. It also detects certain addon capabilities (permission manager and toolbar bar icons) and lets addons run extra bootstrap code.

Addon Manifests

$siteContext['addons'] contains the manifest.php array for each installed addon. Use it to:

  • Build lists of installed addons for dashboards, menus, and consoles.
  • Check addon metadata like title, version, and toolbar flags.

Example

<?php
    foreach ($siteContext['addons'] as $addon) {
        $slug = $addon['title'] ?? '';
        $version = $addon['version'] ?? '';
    }
    

See Also

Bootstrap: Authentication Context

Bootstrap checks login state and exposes user information in $siteContext. Use it to show account UI, protect endpoints, or decide which toolbar tools to render.

Useful Values

  • $siteContext['authenticated'] - True when the user is logged in.
  • $siteContext['user'] - Basic user profile fields (id, username, fullname, email, usertype, picture).
  • $_SESSION['user'] - A copy of the user array is stored in the session when authenticated.

How to Use It

<?php
    if (empty($siteContext['authenticated'])) {
        http_response_code(401);
        exit('Login required');
    }

    if (($siteContext['user']['usertype'] ?? '') === 'owner') {
        // Owner-only feature
    }
    

See Also

Bootstrap: Configuration Flags

Bootstrap reads many project-wide configuration values and exposes them in $siteContext. Use these flags to adjust behavior or UI without touching code.

Common Flags

  • $siteContext['debug'] - Toggle debug behavior in templates and endpoints.
  • $siteContext['use_short_locale'] - Whether URLs use short language codes.
  • $siteContext['hard_remove_files'] - Whether deletes are hard (project-defined usage).

Example

<?php
    if (!empty($siteContext['debug'])) {
        hp_log('Debug enabled for this request', [], 'WARNING');
    }
    

Related Documentation

  • hp_log - Logging errors and debug information (only works when $siteContext['debug'] is enabled)

Bootstrap: Meta and Project Info

Bootstrap initializes page meta defaults and exposes project-level information. Use these values to generate meta tags, page titles, thumbnails, and schema hints.

Meta Defaults

  • $siteContext['title'], $siteContext['description'] - Base title/description (often from locale config).
  • $siteContext['meta'] - Meta array (priority, changefreq, canonical, xcom_card, thumbnail).

Locale Configuration Outputs

  • $siteContext['project_name'], $siteContext['project_type'] - Project metadata used by templates.
  • $siteContext['timezone'] - The active timezone.
  • $siteContext['calendar_type'] - Default calendar key for date formatting.

Example

<?php
    $title = $siteContext['title'] ?? '';
    $thumb = $siteContext['meta']['thumbnail'] ?? '';
    $canonical = $siteContext['meta']['canonical'] ?? '';
    

See Also

  • hp_date - Calendar-aware date formatting for meta fields.

Bootstrap: Request Parameters

Bootstrap parses a small set of common query parameters and exposes them in $siteContext. This keeps controllers and templates consistent.

Useful Values

  • $siteContext['q'] - Search/query input (sanitized).
  • $siteContext['page'] - Pagination number (integer).
  • $siteContext['tab'] - Console tab index (integer).
  • $siteContext['alert'] - UI alert message stored in the session (ui_alert).
  • $siteContext['httpStatus'] - Current status code used by the render flow (e.g., 200/404).

Example

<?php
    $page = max(1, (int)($siteContext['page'] ?? 1));
    $q = (string)($siteContext['q'] ?? '');
    if ($q !== '') {
        // Search mode
    }
    

Bootstrap: Locale and UI Helpers

Bootstrap prepares values that make templates easy to write across RTL/LTR languages and across core/addon translations.

Direction and Alignment

  • $siteContext['direction'] - ltr or rtl.
  • $siteContext['alignment'] - left or right.
  • $siteContext['directionRev'], $siteContext['alignmentRev'] - The opposite direction and alignment.
  • $siteContext['language'] - Two-letter language code (derived from the locale).

Translations

  • $siteContext['dictionary'] - Core translation key/value map for this locale.
  • $siteContext['custom_dictionary'] - Custom overrides for this locale.
  • $siteContext['addons_dictionary'] - Addon translation maps keyed by addon slug.

How to Use It

<?php
    // Direction-aware icons or caret placement
    $caret = 'bi-caret-' . $siteContext['alignmentRev'] . '-fill';

    // Translations
    echo hp_t('home');
    echo hp_t('open_console', 'my_addon');
    

See Also

  • t - In-memory translations for the current request.
  • hp_t_locale - Read translations from a different locale.

Bootstrap: URLs and Routing Context

Bootstrap gives you the key routing values needed to detect the current page and generate links that keep working even when URL mapping is enabled.

Useful Values

  • $siteContext['locale'] - The active locale for this request (e.g., en_us).
  • $siteContext['urlLocale'] - The locale as it appears in the URL (short language code or full locale, depending on configuration).
  • $siteContext['urlData'] - The page identity without locale, as an array of segments.
  • $siteContext['url_path'] - The visible request path segments joined by / (may be mapped).
  • $siteContext['url'] - The raw REQUEST_URI string.

How to Use It

  1. Use $siteContext['urlData'] to decide which page you are on.
  2. Use hp_url($siteContext['locale'], $urlData) to generate links (never hardcode strings).
  3. Use $siteContext['url_path'] only for display/debug, because it can be SEO-mapped.

Example

<?php
    $urlData = $siteContext['urlData'] ?? [];
    $section = $urlData[0] ?? '';
    $slug = $urlData[1] ?? '';

    if ($section === 'blog') {
        // Blog page logic
    }

    $canonical = hp_url($siteContext['locale'], $urlData);
    

See Also

  • urlData - Why urlData and urlPath may not match.
  • hp_url - How URL generation can be overridden by addons.

urlData (Server Side)

urlData is Horuph's internal, locale-agnostic identifier for the current page. It is stored as an array in $siteContext['urlData']. Core uses it to load pages, store block layouts, build breadcrumbs, and generate links through hp_url().

urlData vs urlPath

Horuph deals with two related concepts:

  • urlPath: The visible URL path in the browser (what the user typed). In bootstrap it is stored as $siteContext['url_path'].
  • urlData: The internal route segments without locale. In bootstrap it is stored as $siteContext['urlData'].

When URL mapping is enabled, urlPath and urlData do not need to look similar. The mapping table connects them.

How Bootstrap Builds urlData

During bootstrap (core/bootstrap.php):

  1. The router extracts the request segments into $routerUrlData.
  2. The system detects the locale (using short locale or full locale settings).
  3. $siteContext['url_path'] is set to implode('/', $routerUrlData).
  4. If the urls addon is installed, it can map the request using urls_url_map and replace the request with the mapped url_data (decoded JSON) so the application runs on urlData.
  5. Finally, $siteContext['urlData'] is set to the request segments without the locale prefix.

Result: code can always rely on $siteContext['urlData'] as the page identity, even when the visible URL is SEO-mapped.

How hp_url Uses urlData

hp_url($locale, $urlData) performs the reverse lookup (addons can override):

  • The urls addon looks for a urls_url_map record where locale matches and url_data equals json_encode($urlData).
  • If found, it returns the mapped url_path.
  • If not found, it falls back to /{locale}/{segment1}/{segment2}.

Examples

Example 1: No URL Mapping

Visible URL: /en/blog/post-123
    urlData:      ['blog', 'post-123']
    hp_url():     https://example.com/en/blog/post-123

Example 2: URL Mapping Enabled

Visible URL: /en/about-us
    urlData:      ['pages', 'page-7']
    hp_url('en_us', ['pages','page-7']): https://example.com/en/about-us

In this case, urlData tells the application what content to load, while urlPath stays clean and SEO-friendly.

Where urlData Is Used

  • Page routing and rendering: core logic switches behaviour based on $siteContext['urlData'][0] (section) and $siteContext['urlData'][1] (slug/id).
  • Blocks and layouts: block records often store urlData as a string (joined by /) to group blocks per page.
  • Menus: system links store urlData so the menu can rebuild URLs later in any locale.

How to Use urlData in Code

<?php
    // Current page identity (without locale)
    $urlData = $siteContext['urlData'] ?? [];

    // Build a link to another page
    $link = hp_url($siteContext['locale'], ['blog', 'post-123']);

    // Check the current section
    if (($urlData[0] ?? '') === 'blog') {
        // Blog page
    }
    

Common Mistakes

  • Parsing $siteContext['url_path'] to understand the page. Use $siteContext['urlData'] instead, because urlPath can be mapped.
  • Assuming locale is part of urlData. Locale is always available separately as $siteContext['locale'].
  • Hardcoding URLs as strings. Prefer hp_url($locale, $urlData) so URL mapping and short locales continue to work.

Activity Log (Server Side)

Horuph automatically logs all controller actions (requests to /action/... routes) to the activity log. The activity log records who performed what action, when, from which IP address, and whether the action succeeded or failed.

How Activity Logging Works

When a controller action is called through core/controllers/action.php, Horuph:

  1. Determines the controller file and action name
  2. Includes and executes the controller file
  3. Extracts success and message from the controller's $response array
  4. Writes an activity log entry before redirecting or returning JSON
  5. Includes the response status (success and message) in the log entry

Required Controller Response Structure

Every controller action file MUST return a $response array, even when redirecting. This is required because Horuph uses the response to write activity logs.

Required Fields

Your controller's $response array must always include:

  • success (boolean) - Required. Indicates whether the action succeeded (true) or failed (false).
  • message (string) - Required. A human-readable message describing the result. Can be empty string on success.
  • redirect (string, optional) - If present and non-empty, Horuph will redirect to this URL instead of returning JSON.

Why These Fields Are Required

Horuph's activity log system reads success and message from your controller's $response to:

  • Record whether the action succeeded or failed
  • Store the result message for debugging and auditing
  • Allow filtering logs by success/failure status
  • Provide context about what happened during the action

Even if your controller redirects (using redirect field), you must still provide success and message because the activity log is written before the redirect occurs.

Controller Response Examples

Success with JSON Response

<?php
    // Controller returns JSON (no redirect)
    $response = [
        'success' => true,
        'message' => 'Item saved successfully',
        'data' => ['id' => $itemId]
    ];
    ?>

Success with Redirect

<?php
    // Controller redirects after success
    $response = [
        'success' => true,
        'message' => 'Item saved successfully',
        'redirect' => hp_url($siteContext['locale'], ['console', 'my_addon'])
    ];
    $_SESSION['ui_alert'] = 'success';
    ?>

Failure with Error Message

<?php
    // Controller returns error (no redirect)
    $response = [
        'success' => false,
        'message' => 'Validation failed: Title is required',
        'error' => [
            'code' => 'VALIDATION',
            'fields' => ['title' => 'Required']
        ]
    ];
    ?>

Failure with Redirect

<?php
    // Controller redirects after failure
    $response = [
        'success' => false,
        'message' => 'You are not authorized to perform this action',
        'redirect' => hp_url($siteContext['locale'], ['console'])
    ];
    $_SESSION['ui_alert'] = 'error';
    ?>

Exception Handling (Always Include Response)

<?php
    try {
        // ... perform action ...
        
        $response = [
            'success' => true,
            'message' => 'Action completed successfully'
        ];
    } catch (Exception $e) {
        hp_log('Action Error', ['error' => $e->getMessage()], 'ERROR');
        
        // Always set response even on exception
        $response = [
            'success' => false,
            'message' => 'Error: ' . $e->getMessage(),
            'redirect' => hp_url($siteContext['locale'], ['console', 'my_addon'])
        ];
        $_SESSION['ui_alert'] = 'error';
    }
    ?>

What Happens If Response Is Missing

If your controller doesn't define $response, Horuph will automatically create a default response:

$response = [
        'success' => false,
        'message' => 'Invalid request'
    ];

This default response will be logged as a failed action, which may not accurately reflect what happened in your controller. Always explicitly define $response in all code paths (success, failure, and exception handlers).

Additional Response Fields

After providing the required fields (success, message, and optionally redirect), you can add any additional fields you need:

  • data - For returning data to the client
  • error - For structured error information
  • page, total, per_page - For pagination
  • Any other custom fields your frontend needs

All fields in the $response array will be included in the JSON response (if not redirecting) and can be accessed by your frontend code.

Activity Log Data Structure

Each activity log entry contains:

  • action - The controller action name (e.g., contents/content-save or logout)
  • path - The request URI
  • user_id - ID of the user who performed the action (0 for guests)
  • ip_address - IP address of the requester
  • user_agent - Browser/user agent string
  • extra_json - JSON object containing:
    • method - HTTP method (GET, POST, etc.)
    • query_params - URL query parameters
    • data_keys - Keys from request data
    • data - Request data (with sensitive fields redacted)
    • response_success - Boolean from controller's $response['success']
    • response_message - String from controller's $response['message']
  • created_at - Timestamp of the action

Best Practices

  • Always define $response: In all code paths (success, failure, exceptions), always set the $response array.
  • Always include success and message: These are required for activity logging to work correctly.
  • Use meaningful messages: The message field appears in activity logs, so use clear, descriptive messages.
  • Handle exceptions properly: Wrap your controller logic in try-catch blocks and always set $response in catch blocks.
  • Don't skip response on redirects: Even when redirecting, you must provide success and message because logging happens before the redirect.

Related Documentation

Hooks (Server Side)

Horuph provides a small hook system in core/classes/Hooks.php so core and addons can run custom code at specific moments. Hooks are useful for extending behavior without editing core files.

How It Loads

After the Hooks class is defined, Horuph loops over all installed addons and includes this file when it exists:

core/addons/<addon>/support/hooks.php

This is the recommended place to register your addon listeners.

Two Hook Types

1) Actions (no return)

Actions are events where you just want code to run. Use Hooks::trigger() to fire them and Hooks::on() to listen.

2) Filters (returns a value)

Filters start with a value and let multiple listeners modify it. Use Hooks::filter() to get the final value.

Register a Listener

Use Hooks::on($name, $callback, $priority). Listeners run in ascending priority order (lower number runs earlier). The default priority is 10.

<?php
    // core/addons/my_addon/support/hooks.php

    Hooks::on('my_event', function ($payload) {
        // Do something with $payload
    }, 10);
    

Trigger an Action

Use Hooks::trigger($name, ...$args). The hook does not return anything, and every listener receives the same arguments.

<?php
    Hooks::trigger('my_event', ['id' => 123, 'type' => 'demo']);
    

Use a Filter

Use Hooks::filter($name, $value, ...$args). Each listener receives the current value as the first argument and must return the new value.

<?php
    // Listener
    Hooks::on('my_filter', function ($value, $context) {
        return $value . ' (modified)';
    }, 10);

    // Usage
    $result = Hooks::filter('my_filter', 'original', ['source' => 'server']);
    // $result is the final value after all listeners run
    

Best Practices

  • Use stable hook names (lowercase, dot-separated) like user.registered or page.render.before.
  • Keep hook listeners fast. Hooks can run on every request.
  • Wrap risky logic in try/catch so one addon does not break the whole request.
  • Use priority to control order: 1 for early, 100 for late.

How to Find Available Hooks

Hook names are defined wherever core or addons call Hooks::trigger() or Hooks::filter(). Search the codebase for these calls to find the names and their arguments.

Hooks::trigger(
    Hooks::filter(

Queue Workers (Server Side)

Horuph processes background jobs from the system_queue_jobs table. Developers usually do two things: enqueue jobs with hp_queue_job() and run a worker that executes them.

Where It Lives

core/classes/Supports.php (hp_queue_job, fireWorker)
    core/classes/Jobs.php (DoOneJob)
    core/workers/queue_worker.php (long-running worker)
    core/workers/fire_worker/fire_worker.php (heartbeat worker)
    core/workers/worker_bootstrap.php (worker bootstrap)

Quick Start

  1. Create a job handler function in your addon (core/addons/<addon>/workers/handlers.php).
  2. Enqueue a job using hp_queue_job() with a job type like my_addon,my_handler.
  3. Run a worker (either the heartbeat worker or the long-running worker) to process jobs.

Job Type Format (Important)

The worker parses job_type like this:

  • my_addon,my_handler - calls the PHP function my_handler($payload) (loaded from addon handlers).
  • my_handler - treated as a core job. Core job handlers are not implemented yet.

Create a Job Handler (Addon)

Create (or edit) this file:

core/addons/my_addon/workers/handlers.php

Add a function that returns an array with success and message:

<?php
    function my_handler(array $payload): array {
        // Do work here...
        return [
            'success' => true,
            'message' => 'Done'
        ];
    }

Enqueue a Job

Use hp_queue_job() from server-side code:

<?php
    $jobId = hp_queue_job('my_addon,my_handler', [
        'user_id' => 123
    ], [
        'queue' => 'default',
        'priority' => 0,
        'max_attempts' => 3,
        'available_at' => date('Y-m-d H:i:s', strtotime('+5 minutes'))
    ]);
    

Worker Options

Option 1: Fire Worker (Heartbeat / Web Request)

This mode triggers background processing during normal web traffic. It is started from core/bootstrap.php using fireWorker().

  1. Enable heartbeat in horuph.php config.
  2. Each request triggers a fast background call to /core/workers/fire_worker/....
  3. The worker processes jobs in a loop until the script time limit is reached.

Common config keys in horuph.php:

  • heart_beat - enable/disable heartbeat worker.
  • heart_beat_pulse - sleep between job checks (seconds).
  • heart_beat_on_ip - use 127.0.0.1 calls with Host header.

Option 2: Queue Worker (Long-Running)

This mode is a long-running loop in core/workers/queue_worker.php. It uses a lock file so only one process runs at a time.

  1. Start core/workers/queue_worker.php as a background process (service/CLI task).
  2. It calls DoOneJob('queue_worker') repeatedly.
  3. When the queue is empty, it sleeps longer; when busy, it sleeps briefly.

How Jobs Are Picked and Updated

  • Only jobs with status = pending and available_at <= now are executed.
  • Jobs are ordered by priority DESC, then id ASC.
  • When a job starts, status becomes processing and attempts increases by 1.
  • On success, status becomes completed.
  • On failure, status becomes pending again until max_attempts is reached, then it becomes failed.

Notes

  • The worker updates the system setting last_heart_beat after each loop.
  • Jobs are stored in system_queue_jobs. Make sure the table exists (it is created during install/migrations).
  • For the enqueue API, see hp_queue_job.

Database Class Overview

core/classes/Database.php is Horuph's database layer. It wraps PDO and supports both SQLite and MySQL. Most core helper functions like hp_db_all() and hp_db_one() call into this class under the hood, but you can also use it directly when you need transactions or simple CRUD methods.

What It Provides

  • One shared connection via Database::getInstance() (singleton).
  • A safe prepared-statement runner: $db->query($sql, $params).
  • Convenience CRUD: select(), selectOne(), insert(), update(), delete(), count(), exists().
  • Transactions: beginTransaction(), commit(), rollback(), and transaction().
  • Driver info: getType() and direct PDO access: getPdo().

Quick Start

<?php
    $db = Database::getInstance();
    $rows = $db->select('my_table', ['status' => 'active'], ['orderBy' => 'id DESC', 'limit' => 20]);
    $one = $db->selectOne('my_table', ['id' => 1]);
    

Related Core Docs

  • hp_db_all - Wrapper for selecting multiple rows via prepared queries.
  • hp_db_one - Wrapper for selecting a single row via prepared queries.
  • hp_db_exec - Wrapper for UPDATE/DELETE with row count handling.
  • hp_insert - Wrapper that inserts via raw SQL or table/data mode.
  • hp_db_date_condition - Builds database-agnostic time filters.

Database Configuration

The Database class reads its connection settings from horuph.php. Set database_type to sqlite or mysql, then provide the matching configuration keys.

SQLite

When database_type is sqlite, Horuph builds the PDO DSN from a single file path.

<?php
    return [
        'database_type' => 'sqlite',
        'database' => [
            'path' => '/database/sqlite/database.db3',
        ],
    ];
    

The file path is joined with BASE_PATH, so it should be project-relative.

MySQL

When database_type is mysql, Horuph builds a DSN that includes utf8mb4.

<?php
    return [
        'database_type' => 'mysql',
        'database' => [
            'host' => 'localhost',
            'port' => 3306,
            'name' => 'horuph',
            'user' => 'root',
            'pass' => 'secret',
        ],
    ];
    

Notes

  • PDO error mode is set to exceptions, so query errors throw.
  • Default fetch mode is associative arrays.
  • SQLite enables a cursor mode intended to make rowCount() usable for UPDATE/DELETE.

Database::getInstance

Database::getInstance() returns the shared database connection (singleton). Use it to get a Database object anywhere in core code.

Signature

public static function getInstance(): Database

Example

<?php
    $db = Database::getInstance();
    $type = $db->getType(); // 'sqlite' or 'mysql'
    

Notes

  • The first call initializes the connection by reading horuph.php.
  • The class prevents cloning and unserialization to keep the singleton safe.

Database::getType and Database::getPdo

Use getType() to branch logic for MySQL vs SQLite when needed. Use getPdo() only when you need a feature that is not exposed by Horuph wrappers.

Signatures

public function getType(): string
    public function getPdo(): PDO

Example

<?php
    $db = Database::getInstance();
    if ($db->getType() === 'sqlite') {
        // SQLite-specific query or syntax
    }

    $pdo = $db->getPdo();
    $pdo->setAttribute(PDO::ATTR_TIMEOUT, 3);
    

Notes

  • Prefer the core wrappers (hp_db_all, hp_db_one, hp_db_exec) for normal work.
  • Direct PDO access is powerful but easier to misuse, so keep it contained.

Database::getLastError

getLastError() returns PDO error information. This is mainly useful for debugging when you are working at the raw PDO layer.

Signature

public function getLastError(): array

Example

<?php
    $db = Database::getInstance();
    try {
        $db->query("SELECT * FROM missing_table");
    } catch (Exception $e) {
        $info = $db->getLastError();
        hp_log('DB error', ['error' => json_encode($info)], 'ERROR');
    }
    

Notes

  • Most core helpers throw exceptions or log SQL strings, so you usually do not need this.
  • Error info format is the same as PDO::errorInfo().

Related Documentation

  • hp_log - Logging errors and debug information

Database::query

Database::query() prepares a SQL statement, executes it with parameters, and returns the PDOStatement. It is the base method used by the higher-level wrappers in core.

Signature

public function query(string $sql, array $params = []): PDOStatement

Example

<?php
    $db = Database::getInstance();
    $stmt = $db->query(
        "SELECT id, title FROM my_table WHERE status = ? AND created_at >= ?",
        ['active', '2025-01-01 00:00:00']
    );
    $rows = $stmt->fetchAll();
    

Notes

  • Always use placeholders for values; never concatenate user input into SQL.
  • Throws an exception when execution fails.
  • Fetch mode is configured as associative arrays by default.

Database::select and Database::selectOne

select() and selectOne() are convenience methods for simple lookups by exact-match conditions. They automatically build a WHERE clause using named parameters.

Signatures

public function select(string $table, array $conditions = [], array $options = []): array
    public function selectOne(string $table, array $conditions = []): array|false

Conditions

Pass conditions as ['column' => 'value']. Each key becomes column = :column.

Options

  • orderBy: Raw ORDER BY string (example: id DESC).
  • limit: Integer limit.
  • offset: Integer offset.

Example

<?php
    $db = Database::getInstance();
    $rows = $db->select('my_table', ['status' => 'active'], [
        'orderBy' => 'id DESC',
        'limit' => 50,
    ]);

    $one = $db->selectOne('my_table', ['id' => 10]);
    if ($one === false) {
        // Not found
    }
    

Notes

  • These helpers are best for exact-match filters. For complex filters, use query() (or hp_db_all/hp_db_one).
  • selectOne() calls select() with limit = 1 and returns false when no row exists.
  • orderBy is appended directly to SQL, so do not pass user input into it.

Database::count and Database::exists

count() returns the number of rows in a table that match exact-match conditions. exists() is a convenience wrapper that returns true when the count is greater than zero.

Signatures

public function count(string $table, array $conditions = []): int
    public function exists(string $table, array $conditions = []): bool

Example

<?php
    $db = Database::getInstance();
    $pending = $db->count('my_table', ['status' => 'pending']);
    if ($db->exists('my_table', ['id' => 123])) {
        // Record exists
    }
    

Notes

  • These helpers use SELECT COUNT(*) and are fast when you query indexed columns.
  • For complex conditions (ranges, LIKE, OR), write a query manually and use hp_db_one().

Database::insert, update, and delete

These CRUD helpers cover the common insert/update/delete cases using named parameters. They are useful when you want to avoid writing raw SQL for simple operations.

Signatures

public function insert(string $table, array $data): int
    public function update(string $table, array $data, array $conditions = []): int
    public function delete(string $table, array $conditions = []): int

Insert Example

<?php
    $db = Database::getInstance();
    $newId = $db->insert('my_table', [
        'title' => 'Hello',
        'status' => 'active',
        'created_at' => date('Y-m-d H:i:s'),
    ]);
    

Update Example

<?php
    $affected = $db->update('my_table', ['status' => 'inactive'], ['id' => $newId]);
    

Delete Example

<?php
    $deleted = $db->delete('my_table', ['id' => $newId]);
    

Notes

  • Always include conditions for updates and deletes. Calling them with an empty conditions array updates or deletes the entire table.
  • Insert returns lastInsertId() from PDO.
  • Update/delete return the affected rows count from the executed statement.

Database Transactions

Use transactions when multiple writes must succeed together. The Database class exposes manual transaction methods and a convenience transaction() wrapper that commits or rolls back automatically.

Signatures

public function beginTransaction(): bool
    public function commit(): bool
    public function rollback(): bool
    public function transaction(callable $callback): mixed

Example (transaction callback)

<?php
    $db = Database::getInstance();
    $result = $db->transaction(function (Database $db) {
        $id = $db->insert('my_table', ['title' => 'A', 'status' => 'active']);
        $db->update('my_table', ['status' => 'inactive'], ['id' => $id]);
        return $id;
    });
    

Example (manual)

<?php
    $db = Database::getInstance();
    try {
        $db->beginTransaction();
        $db->insert('my_table', ['title' => 'B']);
        $db->commit();
    } catch (Exception $e) {
        $db->rollback();
        throw $e;
    }
    

Notes

  • The callback version commits when no exception is thrown and rolls back when an exception occurs.
  • Keep the work inside transactions short to avoid locking.

hp_add_user

hp_add_user() inserts a new record into system_users, hashing the password, generating a usertoken, and firing the user.registered hook when successful. Use it whenever you need to programmatically create console users.

Signature

hp_add_user(array $userData): array

Required Keys in $userData

  • username
  • password (plain text; Bcrypt hash is stored)
  • email
  • ip (registration IP address)

Optional Keys

  • status: Defaults to unverified (other values: active, inactive, locked).
  • fullname: Defaults to username.
  • picture: Avatar path/token.
  • usertype: Defaults to user (set owner or custom roles later).

Return Value

An array with success (bool), message, and when successful, the new user_id and usertoken.

Example

<?php
    $result = hp_add_user([
        'username' => 'editor01',
        'password' => 'secret123',
        'email' => 'editor@example.com',
        'ip' => $_SERVER['REMOTE_ADDR'],
        'status' => 'active',
        'usertype' => 'user',
    ]);
    

Notes

  • The helper uses Database::insert(), so database exceptions bubble up via the returned error message.
  • On success it triggers the user.registered hook with the full user payload (including the generated ID).
  • Always validate and sanitize user input before calling this helper.

hp_create_upload

hp_create_upload() renders a reusable upload component complete with label, preview, media-library integration, and optional default files. Use it inside console forms whenever you need a consistent uploader.

Signature

hp_create_upload(array $options = []): string

Key Options

  • id (required): Unique DOM ID.
  • label: Field label (default: "Upload File").
  • name: Input name (defaults to id).
  • accept: Allowed MIME types (e.g., image/*).
  • defaultValue: Existing path/token to show in the preview.
  • buttonText, buttonIcon: Customize the trigger button.
  • required: Boolean to add the required attribute.
  • showPreview: Toggle preview panel.
  • useMediaLibrary: Enables the “Choose from media library” button.
  • saveValueAs: path or token (for uploads saved via media handler).
  • maxFiles: Set to 1 (default) or a higher number for multi-select.

Return Value

Returns the HTML markup for the component so you can echo it inside a form.

Example

<?php
    echo hp_create_upload([
        'id' => 'post_thumbnail',
        'label' => 'Thumbnail',
        'accept' => 'image/*',
        'defaultValue' => $content['thumbnail'] ?? '',
        'useMediaLibrary' => true,
    ]);
    

Notes

  • Automatically detects whether the default value is a token (64 hex chars) and loads metadata to show a preview.
  • When useMediaLibrary is true, the component renders buttons that talk to the media modal JS.
  • Once rendered, rely on the bundled JavaScript (global-admin.js + upload module) for drag/drop, preview, and upload handling.

hp_create_urlmap_input

hp_create_urlmap_input() renders the slug/URL-path editor used across consoles. It handles locale prefixes, title auto-complete, hints for multi-language projects, and exposes data attributes that Horuph’s JavaScript uses to validate uniqueness.

Signature

hp_create_urlmap_input(array $options = []): string

Key Options

  • id (required): DOM ID for the input.
  • name: Field name (defaults to id).
  • defaultValue: Existing slug to prefill.
  • sourceInputId: ID of the title field to auto-generate slugs from.
  • autoComplete: Enable/disable slug auto-fill (default: true).
  • parentPath: Prefix such as blog when every slug sits under a section.
  • showLocalePrefix: Force showing the locale prefix (true/false) or let the helper auto-detect based on available languages.
  • hint: Custom help text below the field.
  • editModeSelector: CSS selector used by JS to detect “editing” state (prevents overwriting existing slugs).

Return Value

HTML markup for the slug input group.

Example

<?php
    echo hp_create_urlmap_input([
        'id' => 'post_slug',
        'sourceInputId' => 'post_title',
        'parentPath' => 'blog',
    ]);
    

Notes

  • Automatically strips the URL locale prefix from defaultValue so editors only see the slug portion.
  • Hint strings are localized via hp_t() so the component works in every language.
  • The rendered data attributes (data-source-input-id, data-parent-path, etc.) are consumed by global-admin.js when validating and auto-completing slugs.

hp_date

hp_date() takes a database date or datetime string, normalizes it into the site locale/calendar/timezone, and returns a formatted string such as 1403/07/01 15:30. It understands Gregorian timestamps, Jalali/Hijri calendars (via MultiCalendar, ICU, or bundled fallbacks), ISO strings, and values that include time.

Signature

hp_date(string $dateString, ?string $locale = null, string $format = 'yyyy-mm-dd', ?string $calendarType = null, ?string $timezone = null): string

Parameters

  • $dateString: Any supported date/datetime string pulled from the database.
  • $locale (optional): Locale to render against. Defaults to the active locale.
  • $format: Pattern that controls delimiters and whether to include time (e.g., yyyy/mm/dd hh:mm).
  • $calendarType (optional): Target calendar key (gregorian, jalali, hijri, etc.). Falls back to locale/site configuration.
  • $timezone (optional): Output timezone ID. Defaults to locale/site configuration.

Return Value

A formatted string or the original input when parsing fails.

Example

<?php
    $publishedAt = hp_date(
        $row['publish_at'],
        null,
        'yyyy/mm/dd hh:mm',
        'jalali',
        'Asia/Tehran'
    );
    

Notes

  • Empty or zero dates return an empty string.
  • Automatically detects date-only vs datetime strings and preserves the time when the format includes hh:mm.
  • Attempts MultiCalendar + ICU conversion first, then falls back to JDF (Jalali) or the bundled Hijri library.
  • When no calendar conversion is available, it returns the Gregorian value formatted with the requested delimiter.

hp_date_custom

hp_date_custom() works like PHP’s date() but respects the site’s locale, timezone, and calendar settings. You pass a format string and a Unix timestamp, and it outputs localized tokens (year, month names, etc.) for Gregorian, Jalali, Hijri, or any MultiCalendar-supported calendar.

Signature

hp_date_custom(string $format, ?int $timestamp = null, ?string $locale = null, ?string $calendarType = null, ?string $timezone = null): string

Parameters

  • $format: PHP-style format string (e.g., 'Y/m/d H:i', 'l, F j').
  • $timestamp (optional): Unix timestamp. Defaults to time().
  • $locale (optional): Locale to render against. Defaults to the current locale.
  • $calendarType (optional): Calendar to convert into. Defaults to locale/site configuration.
  • $timezone (optional): Target timezone ID. Defaults to locale/site configuration.

Return Value

A formatted string using the requested tokens. Falls back to Gregorian output if conversion fails.

Example

<?php
    echo hp_date_custom('l j F Y', strtotime($post['created_at']), null, 'jalali');
    

Notes

  • Uses MultiCalendar/ICU conversion first, then Jalali/Hijri fallbacks, so you get localized year/month/day tokens before the format string runs.
  • Automatically translates certain month/day names for Jalali output (e.g., Persian month names).
  • When the requested calendar is Gregorian or conversion fails, it behaves like a standard timezone-aware date().

hp_db_all

hp_db_all() executes a prepared SELECT query and returns every row as an associative array. It is a thin wrapper around Database::query() that logs SQL errors and always returns an array.

Signature

hp_db_all(string $sql, array $params = []): array

Parameters

  • $sql: SQL statement with placeholders.
  • $params: Values for the placeholders.

Return Value

An array of associative arrays. Returns an empty array when the query fails or finds no rows.

Example

<?php
    $users = hp_db_all(\"SELECT id, username FROM system_users WHERE status = ?\", ['active']);
    

Notes

  • Automatically catches exceptions, logs them with the SQL string, and returns [] to keep pages running.
  • Use hp_db_one() when you only need a single row.

hp_db_date_condition

hp_db_date_condition() builds portable WHERE-clause snippets for filtering by relative time (e.g., last 24 hours). It understands shortcuts like 1h, 7d, as well as custom expressions (5 DAY), and outputs the proper SQL for MySQL or SQLite.

Signature

hp_db_date_condition(string $column, string $filter, string $operator = '>='): string

Parameters

  • $column: Column name to compare (non-alphanumeric characters are stripped for safety).
  • $filter: Relative time (e.g., 24h, 7d, 2 WEEK, 5 MINUTE).
  • $operator: Comparison operator (>=, >, <=, <, =).

Return Value

A SQL fragment or an empty string when the filter is invalid.

Example

<?php
    $condition = hp_db_date_condition('created_at', '24h');
    if ($condition !== '') {
        $sql = \"SELECT * FROM system_queue_jobs WHERE {$condition}\";
    }
    

Notes

  • Generates DATE_SUB(NOW(), INTERVAL ...) for MySQL and datetime('now', '-...') for SQLite.
  • Supports plural units (e.g., '5 DAYS') and automatically normalizes them.
  • Sanitizes the column name but you should still avoid passing user-provided identifiers.

hp_db_exec

hp_db_exec() executes INSERT/UPDATE/DELETE statements and returns the number of affected rows. It wraps Database::query() and works around SQLite’s rowCount() quirks by falling back to changes().

Signature

hp_db_exec(string $sql, array $params = []): int

Parameters

  • $sql: SQL statement with placeholders.
  • $params: Values for the placeholders.

Return Value

The number of affected rows. Throws the original exception when the query fails.

Example

<?php
    $affected = hp_db_exec(\"UPDATE system_users SET status = ? WHERE id = ?\", ['inactive', $userId]);
    

Notes

  • Unlike hp_db_all(), this helper rethrows exceptions so callers can handle transactional logic.
  • Detects SQLite vs MySQL automatically.

hp_insert

hp_db_insert() inserts a row and returns the new ID. It accepts either a raw INSERT statement or a table name plus data array, making it flexible for complex and simple inserts alike.

Signature

hp_db_insert(string $sqlOrTable, array $paramsOrData = []): int|string

Usage Modes

  • Raw SQL: Pass the full INSERT statement and the parameters array.
  • Table/Data: Pass the table name and an associative array; the helper delegates to Database::insert().

Return Value

The last insert ID from PDO/Database.

Example

<?php
    // Raw SQL
    $newId = hp_db_insert(
        \"INSERT INTO system_tags (name, slug) VALUES (?, ?)\",
        [$name, $slug]
    );

    // Table/Data
    $newId = hp_db_insert('system_tags', [
        'name' => $name,
        'slug' => $slug,
    ]);
    

Notes

  • Logs helpful context (SQL or table name) when an exception occurs, then rethrows the error.
  • Ideal for helpers like urls_save_urlmap() that need to insert rows in different ways.

hp_db_one

hp_db_one() runs a prepared SELECT query and returns the first row (or null). It is ideal for lookups where the database is expected to return a single record.

Signature

hp_db_one(string $sql, array $params = []): ?array

Parameters

  • $sql: SQL statement with placeholders.
  • $params: Values for the placeholders.

Return Value

An associative array representing the row, or null if nothing matched or an error occurred.

Example

<?php
    $user = hp_db_one(\"SELECT * FROM system_users WHERE id = ?\", [$userId]);
    if (!$user) {
        throw new RuntimeException('User not found');
    }
    

Notes

  • Logs SQL errors and returns null so you can decide how to handle failures.
  • Automatically fetches rows as associative arrays (PDO::FETCH_ASSOC).

hp_get_setting

hp_get_setting() fetches a key/value pair from the system_settings table, handling database-specific quoting so reserved keywords (like key) are safe. Use it to read project-wide flags that admins can change.

Signature

hp_get_setting(string $key): ?string

Parameters

  • $key: Setting identifier.

Return Value

The stored string value or null when the table is missing or the key does not exist.

Example

<?php
    $maintenanceMessage = hp_get_setting('maintenance_notice');
    if ($maintenanceMessage) {
        echo '<div class=\"alert alert-warning\">' . htmlspecialchars($maintenanceMessage) . '</div>';
    }
    

Notes

  • Automatically detects the database driver (MySQL vs SQLite) to choose the correct SQL syntax.
  • Returns null if system_settings does not exist.
  • Wrap calls in caching logic if you read the same setting repeatedly in a single request.

hp_img_url

hp_img_url() converts a media token or direct path into a usable image URL. When the input is a 64-character token, it returns the optimized /img/<token>-wWIDTH-hHEIGHT-f.webp path; otherwise it returns the original string.

Signature

hp_img_url(string $pictureToken, int $width = 120, int $height = 120): ?string

Parameters

  • $pictureToken: Either a token generated by the media library or a direct file path.
  • $width, $height: Desired dimensions (used when building the optimized token URL).

Return Value

A URL string or null when the input is empty.

Example

<?php
    $avatarUrl = hp_img_url($user['picture'], 64, 64);
    echo '<img src=\"' . htmlspecialchars($avatarUrl) . '\" alt=\"Avatar\">';
    

Notes

  • Use this helper whenever you store tokens in the database and need a thumbnail-sized URL.
  • Supply the same width/height used elsewhere to leverage cached WebP variants.
  • Direct paths (e.g., /public/uploads/foo.png) are returned untouched for legacy files.

hp_log

hp_log() is a developer-friendly logging function that replaces error_log() for better log management. It writes structured log entries to a single log file with timestamps, log levels, and JSON-formatted context data. Use this function instead of error_log() for debugging and monitoring your addon code.

Signature

hp_log(string $message, array $context = [], string $level = 'DEBUG'): void

Parameters

  • $message: A descriptive message describing what happened (e.g., 'User login failed', 'Database query error').
  • $context (optional): An array of additional data to include in the log entry. Can be any key-value pairs. If you pass a non-array value, it will be wrapped as ['value' => $context].
  • $level (optional): Log level string (e.g., 'DEBUG', 'INFO', 'WARNING', 'ERROR'). Defaults to 'DEBUG'.

Behavior

  • Only logs when $siteContext['debug'] is true. If debug mode is disabled, the function returns immediately without writing anything.
  • Writes to storage/logs/horuph.log (relative to BASE_PATH).
  • Each log entry includes a timestamp, log level, message, and JSON-formatted context.
  • Automatically truncates context data if it exceeds 8000 characters to prevent huge log files.
  • Uses file locking (LOCK_EX) to prevent mixed log lines when multiple requests write simultaneously.
  • If context cannot be JSON-encoded, it writes {"_error":"context_json_encode_failed"} instead.

Log File Format

Each log entry is written as a single line in this format:

[YYYY-MM-DD HH:MM:SS][LEVEL] message | {"context":"data"}

Example log entry:

[2024-01-15 14:30:25][ERROR] User login failed | {"user_id":123,"ip":"192.168.1.1","reason":"invalid_password"}

Examples

Simple Debug Message

<?php
    hp_log('Processing order', [], 'DEBUG');
    ?>

Error with Context

<?php
    try {
        // ... some operation ...
    } catch (Exception $e) {
        hp_log('Action Error', ['error' => $e->getMessage(), 'file' => __FILE__, 'line' => __LINE__], 'ERROR');
    }
    ?>

Warning with Data

<?php
    if ($userCount > 1000) {
        hp_log('High user count detected', ['count' => $userCount, 'threshold' => 1000], 'WARNING');
    }
    ?>

Info Log with Complex Context

<?php
    hp_log('Order created', [
        'order_id' => $orderId,
        'user_id' => $userId,
        'total' => $total,
        'items' => count($items)
    ], 'INFO');
    ?>

Database Error Logging

<?php
    $result = hp_db_exec('INSERT INTO my_table (name) VALUES (?)', [$name]);
    if (!$result) {
        $error = Database::getInstance()->getLastError();
        hp_log('DB error', ['error' => json_encode($error), 'query' => 'INSERT INTO my_table'], 'ERROR');
    }
    ?>

When to Use

  • Error handling: Log exceptions and errors with context data for debugging.
  • Debugging: Track execution flow and variable values during development.
  • Monitoring: Log important events (user actions, system events) for analysis.
  • Performance tracking: Log slow operations or resource usage.

Log Levels

Use appropriate log levels to categorize your messages:

  • DEBUG: Detailed information for debugging (default level).
  • INFO: General informational messages about normal operation.
  • WARNING: Warning messages for potentially problematic situations.
  • ERROR: Error messages for failures that need attention.

You can use any string as a log level, but the above are recommended for consistency.

Best Practices

  • Use descriptive messages: Write clear messages that explain what happened.
  • Include relevant context: Add useful data (IDs, values, error details) in the context array.
  • Choose appropriate log levels: Use ERROR for failures, WARNING for concerns, INFO for important events, and DEBUG for detailed tracing.
  • Don't log sensitive data: Avoid logging passwords, tokens, or other sensitive information in the context.
  • Use in exception handlers: Always log exceptions with hp_log() to help with debugging.
  • Remember debug mode: Logs only appear when $siteContext['debug'] is enabled, so don't rely on logs in production unless debug mode is on.

Notes

  • This function is a replacement for PHP's built-in error_log() function. Use hp_log() instead of error_log() for better log management.
  • All logs are written to a single file (storage/logs/horuph.log), making it easier to monitor and search.
  • The log file is created automatically if it doesn't exist.
  • Log entries are appended to the file, so old logs are preserved unless you manually delete the file.
  • If the log file cannot be written (permissions issue), the function fails silently (uses @ to suppress errors).

Related Documentation

hp_queue_job

hp_queue_job() inserts a background job into system_queue_jobs. It validates inputs, JSON-encodes the payload, and sets defaults for queue name, priority, attempts, and scheduled time.

Signature

hp_queue_job(string $jobType, array $payload = [], array $options = []): int|false

Parameters

  • $jobType: Identifier (max 100 chars) such as send_email.
  • $payload: Data array that will be JSON encoded.
  • $options:
    • queue – Queue name (default default).
    • priority – Integer priority (default 0).
    • max_attempts – Retry count (default 3).
    • available_at – DateTime or string for delayed jobs.

Return Value

The inserted job ID or false on failure.

Example

<?php
    $jobId = hp_queue_job('send_email', [
        'to' => $user['email'],
        'template' => 'welcome',
    ], [
        'queue' => 'mail',
        'priority' => 5,
    ]);
    

Notes

  • Ensures the system_queue_jobs table exists before inserting.
  • Logs descriptive errors (including invalid job types or JSON encoding failures).
  • Use available_at to schedule future jobs: pass a DateTime object or a parsable string.

hp_save_urlmap

hp_save_urlmap() is deprecated. URL mapping moved to the urls addon.

Use urls_save_urlmap() and the tables in that addon. See URLs Addon Dev Help.

hp_set_setting

hp_set_setting() inserts, updates, or deletes rows in system_settings. It uses the appropriate UPSERT syntax for MySQL and SQLite, so you can safely save settings without writing vendor-specific SQL.

Signature

hp_set_setting(string $key, ?string $value): bool

Parameters

  • $key: Setting identifier.
  • $value: New value. Pass null to delete the key.

Return Value

true on success, false when the table is missing or an error occurs.

Example

<?php
    hp_set_setting('maintenance_notice', 'Publishing will be offline at 01:00 UTC.');
    hp_set_setting('maintenance_notice', null); // removes the entry
    

Notes

  • Deletes the row entirely when $value is null, which keeps the settings table tidy.
  • Surround calls with try/catch if you need to handle database exceptions differently.
  • Pairs naturally with hp_get_setting() for a simple configuration store.

hp_t

hp_t() fetches translations from the in-memory dictionaries populated during bootstrap. It covers core strings, addon strings, and custom overrides so templates can simply call hp_t('key').

Signature

hp_t(string $key, string $addon = ''): string

Parameters

  • $key: Translation key.
  • $addon: Leave empty for core strings, set to custom for custom_dictionary, or supply the addon slug to read from $siteContext['addons_dictionary'][$addon].

Return Value

The translated string, or a fallback like -key / -addon-key when the entry is missing.

Example

<?php echo hp_t('save'); ?>
    <?php echo hp_t('dashboard_title', 'my_addon'); ?>
    

Notes

  • Because values come from $siteContext, the helper is fast and safe to call in every template.
  • Use hp_t_locale() when you need strings from a locale other than the current one.
  • When localizing addons, keep translation keys consistent with the files under core/addons/<addon>/languages/.

hp_table_exists

hp_table_exists() quickly checks whether a database table exists. It supports MySQL (SHOW TABLES LIKE) and SQLite (sqlite_master) so you can safely gate queries when addons have optional tables.

Signature

hp_table_exists(string $tableName): bool

Parameters

  • $tableName: Name of the table to test.

Return Value

true if the table exists, otherwise false.

Example

<?php
    if (hp_table_exists('newsletter_subscribers')) {
        $stats = hp_db_one(\"SELECT COUNT(*) AS total FROM newsletter_subscribers\");
    }
    

Notes

  • Wraps the check in a try/catch and logs errors instead of throwing.
  • Use it before running migrations or optional features to keep fresh installs clean.

hp_temp_folder

hp_temp_folder() creates a dated (YYYY-MM-DD) subdirectory inside a base temp folder and removes folders older than yesterday. You can optionally append a sanitized subfolder to keep addon-specific files separated.

Signature

hp_temp_folder(string $subfolder = ''): string|false

Parameters

  • $subfolder: Optional child folder (letters, numbers, dashes, underscores only).

Return Value

The full path to today’s folder (with subfolder if provided) or false on failure.

Example

<?php
    $uploadDir = hp_temp_folder(BASE_PATH . '/storage/temp', 'my_addon');
    if ($uploadDir === false) {
        throw new RuntimeException('Unable to create temp folder.');
    }
    

Notes

  • Automatically keeps only today’s and yesterday’s folders under the base path.
  • Uses recursive deletion helpers (iterators if available) to clean up older folders.
  • Returns false and logs errors when directories cannot be created.

hp_t_locale

hp_t_locale() loads translation files from disk for a specific locale (core, custom, or addon) and returns the requested string. Use it when you need to show text in a language different from the active $siteContext['locale'].

Signature

hp_t_locale(string $locale, string $key, string $addon = ''): string

Parameters

  • $locale: Locale folder (e.g., en_us).
  • $key: Translation key.
  • $addon: Leave empty for public/languages/<locale>/dictionary.json, use custom for custom_dictionary.json, or provide an addon slug to read core/addons/<addon>/languages/<locale>.json.

Return Value

The translated string or a fallback such as -key / -addon-key when the entry is missing.

Example

<?php
    echo hp_t_locale('fa_ir', 'hello', 'custom');
    

Notes

  • Reads JSON files directly each time you call it, so avoid using it inside large loops unless you cache the result.
  • Use this helper when generating multi-lingual feeds, meta tags, or reports where the language differs from the UI.

hp_url

hp_url() builds absolute URLs based on a locale and a $urlData array. Addons can override URL generation (the urls addon checks urls_url_map), and if none exists it falls back to /{locale}/{segment1}/{segment2}. The helper respects the project’s protocol, domain, and short-locale setting.

Signature

hp_url(string $locale, array $urlData = []): string

Parameters

  • $locale: Locale to prefix (e.g., en_us, fa_ir).
  • $urlData: Array describing the route segments (e.g., ['contents', 'section-1']).

Return Value

An absolute URL string without a trailing slash.

Example

<?php
    $url = hp_url('en_us', ['contents', 'section-5']);
    // https://example.com/en/contents/section-5 (or mapped path if found)
    

Notes

  • Locale defaults to the site’s default locale when empty.
  • If siteContext['use_short_locale'] is enabled, the helper converts locales to their short language codes (e.g., en).
  • When $urlData is empty, you still get a locale-prefixed home URL.
  • If the urls addon finds a matching path in urls_url_map, the helper returns that SEO-friendly path instead of raw segments.

hp_verify_permission

hp_verify_permission() checks whether the currently authenticated user is allowed to perform an action. Owners automatically pass; other users delegate to the configured permission manager addon.

Signature

hp_verify_permission(string $permission): bool

Parameters

  • $permission: Permission key (e.g., contents_edit, queue_worker_console_access).

Behaviour

  • Returns true immediately when the user is authenticated as owner.
  • If a permission manager addon is configured (see $siteContext['permission_manager']), the helper includes core/addons/<addon>/support/verify_permission.php to evaluate the key.
  • Returns false when the user is not authenticated or when the permission manager denies access.

Example

<?php
    if (!hp_verify_permission('newsletter_campaigns_edit')) {
        http_response_code(403);
        exit('Forbidden');
    }
    

Notes

  • Call this helper inside controllers, console tabs, or AJAX handlers before performing sensitive operations.
  • The permission manager script should return a boolean.
  • Use hp_verify_permission() (note the triple s) when you only need to know if the user can enter the console at all.

hp_client_ip

hp_client_ip() inspects common proxy headers and $_SERVER['REMOTE_ADDR'] to determine the best possible client IP address. It returns the first valid IP it finds or null when none are available.

Signature

hp_client_ip(): ?string

Detection Order

  • HTTP_X_FORWARDED_FOR (first IP before any commas)
  • HTTP_CLIENT_IP
  • HTTP_X_REAL_IP
  • REMOTE_ADDR (fallback)

Example

<?php
    $ip = hp_client_ip();
    if ($ip) {
        logAction('login_attempt', ['ip' => $ip]);
    }
    

Notes

  • The helper does not try to validate private vs public ranges; it only ensures the value looks like an IP address.
  • When proxies append multiple addresses to X-Forwarded-For, only the first one is used.
  • If every source is missing or invalid, you receive null.

hp_copy_directory

hp_copy_directory() recursively copies an entire directory structure from a source location to a destination. It preserves the folder hierarchy, creates missing directories automatically, and copies all files and subdirectories.

Signature

hp_copy_directory(string $source, string $destination): void

Parameters

  • $source: The path to the source directory you want to copy from. Must be a valid directory path.
  • $destination: The path where the directory should be copied to. The destination directory will be created if it doesn't exist.

Return Value

This function does not return a value. It will create directories and copy files, or throw an error if the operation fails.

Example

<?php
    // Copy an addon template to a new location
    $sourceAddon = BASE_PATH . '/core/addons/my_addon';
    $backupPath = BASE_PATH . '/storage/backups/my_addon_backup';

    hp_copy_directory($sourceAddon, $backupPath);
    echo "Addon backed up successfully";
    

How It Works

  1. Creates the destination directory if it doesn't exist, with permissions 0755.
  2. Gets the real path of the source directory to handle symbolic links correctly.
  3. Iterates through all files and subdirectories recursively using RecursiveIteratorIterator.
  4. For each item: creates directories as needed, and copies files to their corresponding paths in the destination.
  5. Preserves the relative folder structure from the source.

Example Use Cases

  • Creating backups of addon folders before updates
  • Duplicating template structures for new projects
  • Copying configuration folders to different environments
  • Moving addon data to a new location while keeping the original

Notes

  • The destination directory will be created automatically if it doesn't exist.
  • If files already exist in the destination, they will be overwritten.
  • This function uses realpath() to resolve symbolic links in the source path.
  • File permissions are not preserved; copied files will have default system permissions.
  • For large directory structures, this operation may take some time to complete.

hp_decode_base32

hp_decode_base32() converts a Base32 string back into the original string (including binary strings). It is case-insensitive.

Signature

hp_decode_base32(string $data): string

How to Create

  1. Start with a Base32 string (for example something you stored earlier).
  2. Call hp_decode_base32($data).
  3. Use the returned decoded string.

Example (round-trip)

<?php
    $original = "hello world";
    $encoded = hp_encode_base32($original);
    $decoded = hp_decode_base32($encoded);
    echo $decoded; // "hello world"
    

Notes

  • Invalid characters are ignored (only A-Z and 2-7 are used).
  • The return value can be a binary string; do not assume it is UTF-8 text.
  • If the input is truncated, extra incomplete bits are dropped.

hp_delete_directory

hp_delete_directory() recursively deletes a directory and all its contents, including subdirectories and files. It safely handles nested folder structures and removes everything inside the target directory before removing the directory itself.

Signature

hp_delete_directory(string $dir): bool

Parameters

  • $dir: The path to the directory you want to delete. Must be a valid directory path.

Return Value

Returns true if the directory was successfully deleted, or false if the path is not a directory or deletion failed.

Example

<?php
    // Delete a temporary folder after processing
    $tempFolder = BASE_PATH . '/storage/temp/old_data';
    if (is_dir($tempFolder)) {
        hp_delete_directory($tempFolder);
        echo "Temporary folder removed";
    }
    

How It Works

  1. Checks if the path is a valid directory. Returns false if not.
  2. Scans the directory contents, excluding . and .. entries.
  3. For each item: if it's a directory, recursively deletes it; if it's a file, deletes it.
  4. Removes the empty directory itself.

Notes

  • This function permanently deletes files and folders. Use with caution.
  • Always check if a directory exists before calling this function to avoid errors.
  • If the directory contains files that are locked or in use, deletion may fail silently.
  • This is a destructive operation with no undo. Consider backing up important data first.

hp_encode_base32

hp_encode_base32() converts a string (including binary strings) into a Base32 string using the characters A-Z and 2-7.

Signature

hp_encode_base32(string $data): string

How to Create

  1. Decide what data you want to encode (for example a random token).
  2. Call hp_encode_base32($data).
  3. Store or send the returned Base32 string.

Example

<?php
    $raw = random_bytes(16);
    $token = hp_encode_base32($raw);
    // Example output: "MZXW6YTBOI======"
    echo $token;
    

Notes

  • The output is uppercase and uses only A-Z and 2-7.
  • This helper does not add = padding characters.
  • Use this when you need a compact, URL-safe-ish token (still escape when building URLs).

hp_export_config

hp_export_config() converts a PHP array into a formatted string representation that you can safely write into configuration files. It keeps indentation consistent, handles nested arrays, and uses var_export() for scalars so values remain valid PHP.

Signature

hp_export_config(array $data, int $indentLevel = 0): string

Parameters

  • $data: The array you want to serialize.
  • $indentLevel (optional): Starting indentation depth. Pass a positive integer when embedding the result inside an existing structure.

Return Value

A string that represents the array in PHP syntax, including newlines and indentation.

Example

<?php
    $config = [
        'title' => 'my_addon',
        'tabs' => [
            ['file' => 'dashboard', 'name' => 'Dashboard'],
        ],
    ];
    $phpCode = '<?php return ' . hp_export_config($config) . ';';
    file_put_contents($path, $phpCode);
    

Notes

  • The helper recurses automatically, so nested arrays keep their structure.
  • Scalar values are exported with var_export(), which keeps strings quoted and booleans or numbers untouched.
  • When the input array is empty, the function returns [].

hp_file_icon

hp_file_icon() maps a file extension (and optionally a MIME type) to a Bootstrap Icons class. Use it when building file browsers so each entry automatically receives a matching icon.

Signature

hp_file_icon(string $extension, string $mimeType = ''): string

Parameters

  • $extension: File extension (with or without dot). Case-insensitive.
  • $mimeType (optional): Reserved for future logic; currently unused but kept for compatibility.

Return Value

A Bootstrap Icons class such as bi-file-earmark-music, bi-file-earmark-pdf, or bi-file-earmark for unknown types.

Example

<?php
    $iconClass = hp_file_icon('pdf');
    echo '<i class="bi ' . $iconClass . '"></i> Report.pdf';
    

Notes

  • The helper covers common categories: audio, video, documents, archives, code, and plain text.
  • Unknown extensions fall back to bi-file-earmark.
  • You can extend the helper inside your fork if you need more granular mappings.

hp_format_bytes

hp_format_bytes() converts a byte value to a human-readable string with adjustable precision. Unlike hp_format_size(), this helper supports units up to terabytes and lets you control the number of decimals.

Signature

hp_format_bytes(int|float $bytes, int $precision = 2): string

Parameters

  • $bytes: Size in bytes.
  • $precision (optional): Number of decimal places (default is 2).

Return Value

A string such as 850 B, 9.77 KB, 5.23 MB, 1.00 GB, or 0.50 TB.

Example

<?php
    $usage = hp_format_bytes($diskUsage, 1);
    echo "Storage used: {$usage}";
    

Notes

  • The helper clamps the unit to the largest defined symbol (B, KB, MB, GB, TB).
  • Values less than zero are treated as zero.
  • Use this helper when you need consistent formatting across dashboards and reports.

hp_format_size

hp_format_size() turns a raw byte count into a readable string with the appropriate unit (B, KB, MB, GB). It uses base 1024 conversions and keeps two decimal places for values above 1 KB.

Signature

hp_format_size(int|float $bytes): string

Parameters

  • $bytes: File size in bytes.

Return Value

A formatted string such as 512 B, 1.50 KB, 12.34 MB, or 3.21 GB.

Example

<?php
    $sizeLabel = hp_format_size(filesize($path));
    echo "Backup size: {$sizeLabel}";
    

Notes

  • Use this helper in UI components where a quick human-readable label is enough.
  • For more granular control (different precision or TB support), see hp_format_bytes().

hp_html_to_text

hp_html_to_text() converts HTML into plain text. It decodes entities, replaces block-level tags with line breaks, strips the remaining tags, and collapses whitespace so the final result is safe for meta descriptions, previews, or logs.

Signature

hp_html_to_text(string $html): string

Parameters

  • $html: Any HTML string (can be empty).

Return Value

A trimmed plain-text string.

Example

<?php
    $html = '<h1>Welcome</h1><p>Thanks for using <strong>Horuph</strong>.</p>';
    $text = hp_html_to_text($html); // "Welcome Thanks for using Horuph."
    

Notes

  • Line breaks are normalized to spaces, so you can safely inject the result into single-line fields.
  • The helper performs multiple passes (regex + strip_tags()) to remove malformed tags as well.
  • It is not intended for generating Markdown or preserving layout; it focuses on quick summaries.

hp_query_part_change

hp_query_part_change() edits or removes a single query parameter inside a full URL while keeping all other pieces intact. It uses parse_url() and http_build_query() internally, so it handles complex URLs, authentication segments, and fragments for you.

Signature

hp_query_part_change(string $url, string $queryName, string $queryValue): string

Parameters

  • $url: The absolute or relative URL to modify.
  • $queryName: The name of the query string key.
  • $queryValue: The new value. Pass an empty string to remove the parameter entirely.

Return Value

A rebuilt URL string with the updated query parameter.

Example

<?php
    $url = 'https://example.com/search?q=horuph&page=2';
    $url = hp_query_part_change($url, 'page', '3'); // https://example.com/search?q=horuph&page=3
    $url = hp_query_part_change($url, 'q', 'docs');  // https://example.com/search?q=docs&page=3
    $url = hp_query_part_change($url, 'page', '');   // removes page parameter
    

Notes

  • Existing query parameters stay untouched unless you target them by name.
  • If the URL previously lacked a query string, the helper adds one automatically.
  • Fragments (the part after #) are preserved.

hp_sanitize

hp_sanitize() normalizes raw input (strings or arrays) by trimming whitespace, stripping backslashes, and converting special characters to HTML entities. It is intended to reduce accidental XSS issues before rendering user-provided content.

Signature

hp_sanitize(mixed $data): mixed

Parameters

  • $data: A string, number, or array. Arrays are sanitized recursively.

Return Value

The sanitized data in the same type as the input (string or array).

Example

<?php
    $payload = [
        'name' => "  Alice <script>alert(1)</script>  ",
        'tags' => ["foo ", " bar"],
    ];
    $clean = hp_sanitize($payload);
    // name => 'Alice &lt;script&gt;alert(1)&lt;/script&gt;'
    // tags => ['foo', 'bar']
    

Notes

  • This helper is not a complete security layer; you still need parameterized queries when working with SQL.
  • Because it calls htmlspecialchars(), the output is HTML-safe but may not be suitable for contexts that expect raw text.
  • Pass null and it will be converted to an empty string.

Part 10: Core Client-side

SITE_DATA

The SITE_DATA object is a global JavaScript variable available on all pages generated by Horuph. It provides easy access to site information, locale settings, URL data, and authentication status without needing to query the server.

Accessing SITE_DATA

Simply use SITE_DATA in any JavaScript code that runs after the page loads. It's defined in the page head before any scripts execute.

console.log(SITE_DATA.locale);
    if (SITE_DATA.authenticated) {
        // User is logged in
    }

Available Properties

URL Information

  • url (string): The current page URL path, e.g., /blog/my-post
  • urlData (object): Complete URL data parsed from the current route, including segments, parameters, and metadata
  • parentData (object): Data from the parent page, if the current page is nested. Empty object if no parent exists
// Get current URL
    const currentUrl = SITE_DATA.url;

    // Access URL segments
    const pageId = SITE_DATA.urlData.id;

    // Check parent page
    if (Object.keys(SITE_DATA.parentData).length > 0) {
        const parentTitle = SITE_DATA.parentData.title;
    }

Locale and Language

  • locale (string): Current locale code, e.g., en_US or fa_IR
  • urlLocale (string): Locale prefix used in URLs, e.g., en or fa
// Load language file
    const dictUrl = `/public/languages/${SITE_DATA.locale}/dictionary.json`;

    // Build localized URL
    const loginUrl = `/${SITE_DATA.urlLocale}/window/login`;

Calendar Type

  • calendarType (string): The calendar system in use, defaults to gregorian. Other common values include jalali or islamic
if (SITE_DATA.calendarType === 'jalali') {
        // Use Persian calendar formatting
    }

Text Direction and Alignment

  • direction (string): Text direction, either ltr (left-to-right) or rtl (right-to-left)
  • directionRev (string): Reverse of direction. If direction is ltr, this is rtl, and vice versa
  • alignment (string): Text alignment, either left or right
  • alignmentRev (string): Reverse of alignment. If alignment is left, this is right, and vice versa
// Apply direction-aware styles
    document.body.style.direction = SITE_DATA.direction;

    // Use alignment for UI elements
    const buttonStyle = {
        float: SITE_DATA.alignment,
        marginLeft: SITE_DATA.direction === 'rtl' ? '10px' : '0'
    };

Authentication

  • authenticated (boolean): true if the current user is authenticated, false otherwise
if (SITE_DATA.authenticated) {
        // Show authenticated user features
        showAdminPanel();
    } else {
        // Show login button
        showLoginForm();
    }

Actions and Badges

  • storedActions (function): Returns stored action data as a string. Returns empty string by default
  • badges (array): Array of badge data. Empty array by default, can be populated by addons
// Get stored actions
    const actions = SITE_DATA.storedActions();

    // Check for badges
    if (SITE_DATA.badges.length > 0) {
        SITE_DATA.badges.forEach(badge => {
            displayBadge(badge);
        });
    }

Common Use Cases

Building Localized URLs

// Redirect after login
    window.location.href = `/${SITE_DATA.urlLocale}/dashboard`;

    // Open a modal window
    newWinbox({
        url: `/${SITE_DATA.urlLocale}/window/settings`
    });

Conditional Rendering Based on Authentication

if (SITE_DATA.authenticated) {
        document.getElementById('user-menu').style.display = 'block';
        document.getElementById('login-button').style.display = 'none';
    }

RTL/LTR Aware UI Adjustments

// Set correct alignment for buttons
    const buttons = document.querySelectorAll('.action-button');
    buttons.forEach(btn => {
        btn.style.textAlign = SITE_DATA.alignment;
        btn.style.marginRight = SITE_DATA.direction === 'rtl' ? '0' : '10px';
        btn.style.marginLeft = SITE_DATA.direction === 'rtl' ? '10px' : '0';
    });

Loading Language Resources

// Load custom dictionary
    fetch(`/public/languages/${SITE_DATA.locale}/custom_dictionary.json`)
        .then(response => response.json())
        .then(data => {
            // Use translation data
        });

    // Load addon language file
    fetch(`/core/addons/my_addon/languages/${SITE_DATA.locale}.json`)
        .then(response => response.json())
        .then(data => {
            // Use addon translations
        });

Notes

  • SITE_DATA is available immediately when the page loads. You don't need to wait for any events.
  • All properties are read-only. Do not try to modify them directly.
  • The object is created server-side, so all values are guaranteed to be correct for the current page context.
  • urlData and parentData are complete objects. Check their structure by logging them: console.log(SITE_DATA.urlData)
  • For empty arrays or objects, always check their length or use Object.keys() before accessing properties.

WindowBox (Client Side)

WindowBox is a small client-side helper that opens a draggable window on top of the page. You can load HTML into it from a URL (recommended) or provide inline HTML.

Where It Lives

The source file is:

public/assets/js/window-box.js

It is included in the global JavaScript bundle, so newWinbox() is available on both console and content pages.

Quick Start

  1. Call newWinbox() from your page JS (or inline onclick).
  2. Pass title and either url or html.
  3. Optionally set width, maximizable, fullscreen, and onClose.

Open a Window From a URL (Recommended)

This fetches HTML and injects it into the window body.

newWinbox({
        title: 'Example Window',
        width: '600px',
        url: '/en_us/window/about'
    });

Tip: you can also open addon windows:

newWinbox({
        title: 'My Addon Tool',
        width: '700px',
        url: '/en_us/my_addon/window/my-tool'
    });

Open a Window Using an Iframe

For pages with complex scripts, documentation pages, or when you need full page isolation, use iframe: true. This loads the URL in an iframe instead of fetching and injecting HTML.

newWinbox({
        title: 'Help Documentation',
        width: '900px',
        height: '600px',
        url: '/shared/help',
        iframe: true
    });

When to use iframe:

  • Pages with complex JavaScript that needs to run in its own context
  • Documentation pages that have their own navigation and scripts
  • External content or pages that should be isolated
  • When you want the page to behave exactly as if opened in a new tab

Open a Window From Inline HTML

newWinbox({
        title: 'Hello',
        width: '420px',
        html: '<div class="p-3">This is inline HTML.</div>'
    });

Title HTML

The window title is inserted as HTML, so you can include an icon:

newWinbox({
        title: '<i class="bi bi-info-circle"></i> Info',
        width: '520px',
        url: '/en_us/window/about'
    });

Common Options

  • title (string) - Window title HTML.
  • width (string) - Max width (example: 600px).
  • height (string) - CSS height (default: auto).
  • url (string) - Load content using fetch().
  • iframe (boolean) - If true, load url in an iframe instead of fetching and injecting HTML. Useful for pages with complex scripts or when you need full page isolation.
  • html (string) - Set body HTML directly.
  • transparentBackdrop (boolean) - Make the overlay transparent (still blocks clicks below).
  • maximizable (boolean) - Show maximize/minimize button.
  • fullscreen (boolean) - Open maximized. If maximizable is false, it opens maximized but does not show the minimize button.
  • onClose (function) - Callback called when the window closes.

URL Content Notes

When using url without iframe (default fetch mode):

  • While loading, the window shows a small loading indicator.
  • After HTML is loaded, any <script> tags inside the loaded content are executed.
  • If the loaded content contains textarea.html-preserve-editor, WindowBox tries to initialize those editors automatically.
  • If window.initUrlMapAutoComplete() exists, WindowBox calls it after load.

When using iframe: true:

  • The URL is loaded in an iframe, providing full page isolation.
  • All scripts and styles from the loaded page run in their own context.
  • The page behaves exactly as if opened in a new tab or window.
  • Useful for documentation, help pages, or any page with complex initialization.

Close and Update the Window

newWinbox() returns a WindowBox instance.

const win = newWinbox({
        title: 'Closable',
        width: '500px',
        html: '<div class="p-3">...</div>'
    });

    // Update
    win.setTitle('New title');
    win.setContent('<div class="p-3">Updated content</div>');

    // Close
    win.close();

Working With Multiple Windows

// Close all windows
    WindowBox.closeAll();

    // Close the last (top) window
    WindowBox.closeLastLayers(1);

    // Get the active window
    const active = WindowBox.getActiveWindow();

Notes

  • newWinbox() also accepts a string (it becomes the title): newWinbox('My Window').
  • Loaded HTML scripts are executed after load. If your loaded content depends on global initializers, call them after load (or include scripts in the loaded HTML).
  • Keep window content lightweight; it can be used in many places (console and content pages).
  • If you use inline onclick, remember to end with return false; to prevent navigation.

File Upload Component (Client Side)

This file upload component shows a preview (image or icon), supports single or multiple files, and can optionally pick files from the media library. In most cases you do not call JavaScript manually; you render the HTML and the component works.

Where It Lives

core/classes/Supports.php (hp_create_upload)
    public/assets/js/global.js (handleFileUpload, cancelFileUpload, initializeFileUploadPreviews)
    public/assets/css/global.css (file upload styles)

Quick Start (Recommended)

  1. Render the component using hp_create_upload() in your PHP view.
  2. Make sure public/assets/css/global.css and public/assets/js/global.js are included on the page.
  3. Submit the form normally. The component updates hidden inputs that the backend can read.

PHP Example

<?php
    echo hp_create_upload([
        'id' => 'thumbnail',
        'name' => 'thumbnail',
        'label' => 'Thumbnail',
        'accept' => 'image/*',
        'defaultValue' => $content['thumbnail'] ?? '',
        'useMediaLibrary' => true,
        'saveValueAs' => 'token',
        'maxFiles' => 1
    ]);
    ?>

How It Works (What The HTML Contains)

  • A hidden file input (.file-upload-input) that calls handleFileUpload(this) on change.
  • A hidden input {id}_current that stores the current value to save (path, token, or a JSON array for multiple).
  • A wrapper .file-upload-wrapper with data attributes that control behavior.
  • UI blocks for placeholder and preview (.file-upload-placeholder, .file-upload-preview).

Single File vs Multiple Files

The mode is controlled by data-max-files on .file-upload-wrapper:

  • data-max-files="1" - single file mode.
  • data-max-files="2" (or higher) - multiple file mode.
  • data-max-files="0" - unlimited multiple files.

In multiple mode, the hidden input {id}_current stores a JSON array of values.

Saving Path vs Token

Use saveValueAs (or wrapper attribute data-save-value-as) to control what is saved:

  • path - saves file paths.
  • token - saves media tokens when available.

Using The Media Library

If you set useMediaLibrary to true, the upload button opens the media selector window instead of the native file picker.

  • Opener: openFileUploadMediaLibrary(inputId) (WindowBox window: /{urlLocale}/window/media-select).
  • Callback: window.insertFileUploadImage(imagePath, token, uploadId, ...) updates the preview and hidden value.

Default Values and Preview

  • Use defaultValue as a path or a 64-character token.
  • On page load, initializeFileUploadPreviews() reads data-default-value and shows the correct preview.
  • For multiple files, PHP may provide data-files-info (names, preview URLs) to build a better initial list.

Cancel / Remove

  • Single mode uses cancelFileUpload(inputId) to clear the selection and preview.
  • Multiple mode uses removeFileFromList(inputId, fileId) to remove one item and update {id}_current.

Notes

HTML Preserve Editor (Client Side)

This editor turns a textarea into a WYSIWYG editor that tries to keep your HTML as close as possible to what you wrote. It also includes a paste cleaner so copied content does not bring scripts or unsafe links.

Where It Lives

public/assets/js/html-preserve-editor.js
    public/assets/css/html-preserve-editor.css

The editor auto-initializes on page load (DOMContentLoaded). If you add a textarea later with JavaScript, call window.initHtmlPreserveEditor(textarea).

Quick Start

  1. Include the CSS and JS.
  2. Add a textarea with class html-preserve-editor.
  3. Submit the form normally; the editor keeps the textarea value updated.

Basic Example

<link href="/public/assets/css/html-preserve-editor.css" rel="stylesheet">
    <script src="/public/assets/js/html-preserve-editor.js"></script>

    <textarea
        class="html-preserve-editor"
        name="content"
    ></textarea>

Common Data Attributes

  • data-name - sets the textarea name if name is missing.
  • data-id - stable editor id (used by some helpers like image insert).
  • data-placeholder - placeholder text for the visual editor.
  • data-min-height - default 300px.
  • data-max-height - default none.
  • data-auto-height - set to 1 to make the editor automatically grow with content while maintaining vertical scroll if content exceeds available space.
  • data-mode - toolbar mode, default full (examples: standard, basic, minimal).

Security (Paste and Save)

The editor always cleans pasted HTML to remove dangerous tags (like scripts) and unsafe attributes (like onclick). You can control how strict it is with styles, PHP blocks, and whether to also sanitize HTML/source mode before saving.

Recommended: security options

Use data-security-options with a comma-separated list:

  • style - removes inline style="..." from pasted content.
  • php - removes PHP blocks on save and removes PHP placeholders on paste.
  • scripts - sanitizes the stored HTML on save (this also covers HTML/source mode).
<textarea
        class="html-preserve-editor"
        name="content"
        data-security-options="scripts,style"
    ></textarea>

Preset: security level

If you prefer a simple number, use data-security-level:

  • 0 - lowest (same as legacy data-secure-input="false").
  • 1 - strips inline styles on paste (same as legacy data-secure-input="true").
  • 2 - sanitizes stored HTML on save (covers HTML/source mode too).
  • 3 - like 2, plus strips inline styles and PHP blocks.
<textarea
        class="html-preserve-editor"
        name="content"
        data-security-level="2"
    ></textarea>

Legacy: secure input

data-secure-input is still supported for older pages. It only controls inline styles on paste:

<textarea
        class="html-preserve-editor"
        name="content"
        data-secure-input="true"
    ></textarea>

Paste and Content Limits

You can set client-side limits to prevent huge pastes or very large embedded images:

  • data-max-chars - max total HTML characters (default 200000).
  • data-max-paste-chars - max characters per paste (default 30000).
  • data-max-data-image-bytes - max bytes per embedded data:image (default 1000000).
<textarea
        class="html-preserve-editor"
        name="content"
        data-max-chars="120000"
        data-max-paste-chars="15000"
        data-max-data-image-bytes="500000"
    ></textarea>

Working With PHP Blocks

If your HTML contains <?php ... ?>, the visual editor shows it as a non-editable placeholder so it is not lost by the browser.

If you want to block PHP completely, enable php in data-security-options (or use data-security-level="3").

Global Helper Functions

  • window.initHtmlPreserveEditors() - scans the page and initializes all matching textareas.
  • window.initHtmlPreserveEditor(textarea) - initializes a single textarea.
  • window.getHtmlPreserveEditor(editorId) - returns an editor instance by id.

Notes

  • This is a client-side helper. Always validate and clean HTML again on the backend before saving to the database.
  • Browsers can still change HTML while editing (this is normal for contenteditable editors).
  • You can customize toolbar modes by editing window.HtmlPreserveEditorModes before initializing.

DateTime Picker (Client Side)

Horuph includes a lightweight date/time picker that replaces an input with a clickable placeholder and a popup calendar. It works on both console and content pages.

Where It Lives

public/assets/js/datetime-picker.js

The picker is included in the global JavaScript bundle and auto-initializes on page load.

Quick Start

  1. Add an input with class h-datetime-picker.
  2. Set a Gregorian value (optional) like YYYY-MM-DDTHH:mm (or YYYY-MM-DD).
  3. Submit the form normally; the picker writes the final value into the input.

Basic Example (Date Only)

<input
        class="h-datetime-picker"
        name="published_at"
        value="2025-12-22"
    >

If you provide a date-only value (YYYY-MM-DD), the picker normalizes it to YYYY-MM-DDT00:00 to keep HTML5 datetime formats consistent.

Date + Time Example

<input
        class="h-datetime-picker"
        name="starts_at"
        data-picker-time="1"
        value="2025-12-22T14:30"
    >

Use data-picker-time="1" to show hour/minute inputs inside the popup.

Calendar Type

The input value is always stored as Gregorian for database safety. The picker can display other calendars while still writing a Gregorian value to the input.

<input
        class="h-datetime-picker"
        name="birthday"
        data-picker-calendar-type="jalali"
        value="1990-01-15"
    >
  • data-picker-calendar-type overrides the site default calendar (which comes from window.SITE_DATA.calendarType when available).
  • Supported display calendars include gregorian, jalali, hijri, hebrew, buddhist, coptic, ethiopian.
  • chinese is treated as unsupported in the UI and falls back to gregorian.
  • For some non-Jalali calendars, the picker may call the conversion endpoint /{urlLocale}/date_converter/endpoint/convert for specific operations.

Modal Mode (Useful Inside WindowBox)

When an input is inside a floating window, the popup can be shown as a centered modal with an overlay:

<input
        class="h-datetime-picker"
        name="due_at"
        data-picker-time="1"
        data-picker-modal="1"
    >

Customize the Icon

Set a Bootstrap icon class using data-help-icon:

<input
        class="h-datetime-picker"
        name="event_at"
        data-help-icon="bi bi-alarm"
    >

Disable Past Dates

Prevent users from selecting past dates and times by setting data-picker-disable-past="1":

<input
        class="h-datetime-picker"
        name="scheduled_at"
        data-picker-time="1"
        data-picker-disable-past="1"
    >

When enabled, all dates and times before the current moment are disabled in the calendar. This is useful for scheduling future events or appointments.

Validation and Required Fields

  • If the input is required, the wrapper shows a required marker.
  • On invalid submit, the picker adds is-invalid styles to match Bootstrap validation.
  • If the input is disabled, the picker does not open.

Global Helper Functions

The script exposes a few helpers on window:

  • closeAllPickers(exceptInstance) - close all open pickers.
  • hasOpenModalPicker() - returns true when a modal picker is open.
  • closeTopModalPicker() - closes the topmost modal picker.
  • reinitializePicker(inputElement) - rebuild one picker (useful after changing data attributes).

Notes

  • The picker auto-initializes for dynamically added inputs using a MutationObserver.
  • If a global translation function window.trans(key) exists, the picker uses it for labels like "Apply" and "Cancel".

Console Pagination (Client Side)

This helper builds a Bootstrap-style pagination UI for console lists. You create one ConsolePagination instance and call update() after you load data.

Where It Lives

public/assets/js/console-pagination.js

It is usually included in the console JS bundle (core/views/statics/js.php?file=console.js).

Quick Start

  1. Add an info element and a pagination list (<ul>) with stable ids.
  2. Create a ConsolePagination instance with those ids.
  3. When your API data loads, call await pagination.update(total, currentPage, totalPages) (make sure your function is async or use an async callback).

HTML Example

<div class="d-flex justify-content-between align-items-center">
        <div>
            <span id="productsInfo" class="text-muted small"></span>
        </div>
        <div>
            <nav>
                <ul id="productsPaginationList" class="pagination pagination-sm mb-0"></ul>
            </nav>
        </div>
    </div>

JavaScript Example

let currentPage = 1;
    const perPage = 50;

    const pagination = new ConsolePagination({
        infoElementId: 'productsInfo',
        paginationListId: 'productsPaginationList',
        perPage: perPage,
        itemName: 'products',
        onChangePage: (page) => {
            currentPage = page;
            loadProducts();
            document.querySelector('.flex-grow-1.overflow-auto').scrollTop = 0;
        }
    });

    async function loadProducts() {
        fetch(`/api/products?page=${currentPage}&per_page=${perPage}`)
            .then(r => r.json())
            .then(async data => {
                // renderProducts(data.items);
                await pagination.update(data.total, data.page, data.total_pages);
            });
    }

Options

  • infoElementId (required) - id of the element that shows "Showing X-Y of Z ...".
  • paginationListId (required) - id of the <ul> that receives pagination items.
  • perPage (optional) - items per page (default: 20).
  • itemName (optional) - label used in the info text (default: items).
  • maxVisible (optional) - max visible page numbers (default: 5).
  • onChangePage(page) (optional) - callback called when the user clicks another page.

Notes

  • If the info element or pagination list is missing, update() logs a warning and does nothing.
  • The update() method is async and uses await trans() for internationalization. Button labels (Previous/Next) and info text (Showing) are automatically translated using the global trans() function.
  • When calling pagination.update(), make sure to use await if you're in an async function, or wrap it in an async callback (e.g., .then(async data => { await pagination.update(...); })).
  • Common console pattern: initialize pagination lazily (create it only when the elements exist) and store the instance in a page variable.

Confirmation Windows (Client Side)

Horuph includes two confirmation windows you can open with WindowBox. Use them when a user must confirm a destructive action like delete, logout, or bulk operations.

Where It Lives

core/views/windows/confirm.php
    core/views/windows/confirm-async.php

Quick Start (Standard Confirm)

This is the simplest option. It submits a normal POST to an action endpoint.

  1. Build a URL like /{urlLocale}/window/confirm?act=logout&return=/en_us/console.
  2. Open it using newWinbox().
  3. If the user clicks "Yes", the window submits a POST to the action.

Example

const title = await trans('confirm_delete');
    const url = `/${SITE_DATA.urlLocale}/window/confirm?act=urls/redirect-delete&q=${encodeURIComponent(id)}&return=${encodeURIComponent(SITE_DATA.url)}`;
    newWinbox({ title, width: '450px', url });

⚠️ Important: Keep Titles Short

WindowBox titles have limited space in the header bar. Keep titles short (2-4 words maximum). Use short, action-oriented titles like "Delete", "Generate Key", "Confirm Delete", etc.

❌ Bad (too long):

const title = await trans('confirm_delete_key', 'harph'); // Too long for title bar
    // "Are you sure you want to delete your signing key? This action cannot be undone..."

✅ Good (short and clear):

const title = await trans('delete_key', 'harph'); // Short: "Delete Key"
    // Use 'message' parameter for longer text (see async confirm example above)

Query Parameters

  • act (required) - action name. For addon actions use {addon}/{action} (example: users/logout-session).
  • return (optional) - passed into the POST as return_value (your action can use it for redirect).
  • id, token, type, name, q (optional) - included as hidden inputs in the POST.
  • Any other query params (except act and return) are also sent as hidden inputs.

Quick Start (Async Confirm)

This option submits the action with fetch() and expects a JSON response.

  1. Optionally set a callback in SITE_DATA.storedActions (a function).
  2. Open /{urlLocale}/window/confirm-async?act=... using newWinbox().
  3. On success, the window closes and runs SITE_DATA.storedActions() once.

Example (Refresh UI After Delete)

SITE_DATA.storedActions = function () {
        refreshList();
    };

    const title = await trans('confirm_delete');
    const url = `/${SITE_DATA.urlLocale}/window/confirm-async?act=logs-delete-all&close=all`;
    newWinbox({ title, width: '450px', url });

Example (Short Title with Custom Message)

Important: WindowBox titles have limited space and should be kept short (2-4 words). Use the message query parameter for longer confirmation messages.

SITE_DATA.storedActions = function() {
        window.location.reload();
    };

    const title = await trans('delete_key', 'harph'); // Short title: "Delete Key"
    const message = await trans('confirm_delete_key_message', 'harph'); // Long message
    const url = `/${SITE_DATA.urlLocale}/window/confirm-async?act=harph/key-delete&close=current&message=${encodeURIComponent(message)}`;
    newWinbox({ title, width: '450px', url });

Query Parameters

  • act (optional) - action name posted to /{urlLocale}/action/{act}.
  • close (optional) - current (default) or all.
  • message (optional) - custom confirmation message text. If not provided, uses default confirm_message translation.
  • id, token, type, name, status (optional) - included as hidden inputs in the POST.

Required Action Response

The action endpoint must return JSON like this:

{
        "success": true
    }

Client-Side Only Confirm (No Action)

If act is empty, the async confirm window does not call the server. It only runs SITE_DATA.storedActions() and then closes.

SITE_DATA.storedActions = function () {
        doSomethingClientSide();
    };
    newWinbox({
        title: 'Confirm',
        width: '450px',
        url: `/${SITE_DATA.urlLocale}/window/confirm-async`
    });

Notes

  • The confirmation message text comes from the dictionary key confirm_message.
  • SITE_DATA.storedActions must be a function (not a string). The window clears it before execution to avoid double runs.
  • Use standard confirm when you want normal form submit/redirect; use async confirm when you want to stay on the page and refresh UI after success.

⚠️ Important: Never Use JavaScript's Built-in Alert/Confirm

NEVER use JavaScript's built-in alert() or confirm() functions. Always use Horuph's confirmation windows instead.

Why?

  • User Experience: Native browser dialogs block the entire page and cannot be styled to match your design.
  • Consistency: WindowBox confirm windows match your application's UI/UX.
  • Accessibility: Native dialogs have limited accessibility options.
  • Mobile Compatibility: Native dialogs can cause issues on mobile devices.

❌ Bad (Don't Do This)

async function deleteItem() {
        if (!confirm('Are you sure?')) {
            return;
        }
        // ... delete logic
        alert('Deleted successfully');
    }

✅ Good (Do This Instead)

async function deleteItem() {
        SITE_DATA.storedActions = function() {
            refreshList();
        };
        
        const title = await trans('confirm_delete');
        const url = `/${SITE_DATA.urlLocale}/window/confirm-async?act=items/delete&id=${id}`;
        newWinbox({ title, width: '450px', url });
    }

For Error Messages

Instead of alert() for errors, use toast notifications:

// ❌ Bad
    alert('Error: ' + errorMessage);

    // ✅ Good
    if (window.showToast) {
        window.showToast(errorMessage, 'error');
    }

Summary

  • Confirmation dialogs: Use window/confirm or window/confirm-async
  • Error messages: Use window.showToast() with type 'error'
  • Success messages: Use window.showToast() with type 'success', or rely on $_SESSION['ui_alert'] and redirect
  • Never: Don't use alert(), confirm(), or prompt()

Signature Pad Input (Client Side)

This component turns a hidden input into a signature canvas. You do not need to call JavaScript manually; you only add the right class and data attributes.

Where It Lives

public/assets/js/signature.js
    public/assets/css/global.css

Quick Start (No Manual JS)

  1. Add a hidden input with class signature-pad-input.
  2. Optionally set data-height and data-placeholder.
  3. When the page loads, the script inserts a canvas UI and writes the signature into the input value.

Basic Example

<form method="POST">
        <input
            type="hidden"
            id="userSignature"
            name="signature"
            class="signature-pad-input"
            data-height="200"
            data-placeholder="Draw your signature here"
            value=""
        >
        <button type="submit">Submit</button>
    </form>

Saved Value Format

The input value becomes a PNG data URL (base64), like:

data:image/png;base64,iVBORw0KGgoAAAANS...

Loading an Existing Signature

To show an existing signature, set the input value to a PNG data URL before the page loads.

<input
        type="hidden"
        id="userSignature"
        name="signature"
        class="signature-pad-input"
        value="data:image/png;base64,iVBORw0KGgoAAAANS..."
    >

Data Attributes

  • data-height - canvas height in pixels (default: 200).
  • data-placeholder - placeholder text shown when empty (default: Draw your signature here).

Required and Disabled

  • Add required to block form submit when the signature is empty.
  • Add disabled to prevent drawing and to disable the input.
<input
        type="hidden"
        name="signature"
        class="signature-pad-input"
        required
    >

Clear Button

After the user starts drawing, a clear button appears inside the signature container. Clicking it clears the canvas and sets the input value to an empty string.

Manual Initialization (Dynamic Content)

If you insert new inputs dynamically after page load, the script re-initializes automatically using a MutationObserver. You normally do not need manual calls.

Notes

  • This component depends on the SignaturePad vendor library (SignaturePad) and the translation helper trans() for the clear button label.
  • When using the built-in combined JS bundle (core/views/statics/js.php?file=global.js), the required scripts are included in the correct order.

Relative Time (Client Side)

This helper turns timestamps into human-readable text like 5 min ago and keeps updating it automatically.

Where It Lives

public/assets/js/global.js

Quick Start (No Manual JS)

  1. Add class relative-time to an element.
  2. Add data-datetime with a datetime string.
  3. On page load, the script auto-initializes and fills the element text.

Basic Example

<span class="relative-time" data-datetime="2025-12-22 14:30:00"></span>

Datetime Formats

The helper supports:

  • ISO format: 2025-12-22T14:30:00 or 2025-12-22T14:30:00Z
  • SQL-like format: YYYY-MM-DD HH:mm:ss (example: 2025-12-22 14:30:00)
  • Anything JavaScript new Date(value) can parse

Note: the YYYY-MM-DD HH:mm:ss format is treated as a local time.

Special Cases

  • If data-datetime is empty or equals N/A, it shows N/A.
  • If the datetime cannot be parsed, it shows the original string as-is.
  • If the date is older than 7 days, it shows toLocaleDateString().

Update Frequency

The page runs updateRelativeTimes() automatically on load, then updates on an interval (currently every 15 seconds).

Manual Update (Dynamic Content)

If you add new .relative-time elements after page load, call:

await updateRelativeTimes();

Note: updateRelativeTimes() is an async function, so make sure to use await if you're in an async context, or wrap it in an async callback.

Direct Usage (Programmatic)

You can also call formatRelativeTime() directly in your code:

async function displayTime() {
    const datetimeString = '2025-12-22 14:30:00';
    const relativeText = await formatRelativeTime(datetimeString);
    element.textContent = relativeText;
}

Note: formatRelativeTime() is an async function and uses await trans() for internationalization, so you must use await when calling it.

Internationalization

The relative time strings are automatically translated using the global trans() function. The following dictionary keys are used:

  • less_than_a_min_ago - for times less than 1 minute
  • min_ago - for minutes (e.g., "5 min ago")
  • hour / hours - for hours (e.g., "2 hours ago")
  • day / days - for days (e.g., "3 days ago")
  • ago - the "ago" suffix

All strings are read from the dictionary files (public/languages/{locale}/dictionary.json), making the output fully localized.

Notes

  • Both formatRelativeTime() and updateRelativeTimes() are async functions that use await trans() for internationalization.
  • If you call initRelativeTimes() more than once, it will create multiple intervals. Prefer calling updateRelativeTimes() for dynamic updates.
  • When calling formatRelativeTime() directly, make sure to use await (e.g., const text = await formatRelativeTime(datetimeString);).

getBasename() (Client Side Global)

getBasename(path) returns the last segment of a path string. It is commonly used to show a file name from a URL or file path.

Where It Lives

public/assets/js/global.js

Quick Start

  1. Start with a path like /uploads/a/b/file.pdf.
  2. Call getBasename(path).
  3. Use the returned name in your UI.

Signature

getBasename(path)

Examples

getBasename('/uploads/report.pdf'); // "report.pdf"
    getBasename('report.pdf');          // "report.pdf"

Notes

  • This helper splits on / only.
  • If path is empty, it returns an empty string.

Console Settings Timezone Field (Input Protector)

This console settings field uses the input protector pattern: it shows the current timezone as text and reveals a timezone dropdown only after clicking the edit icon.

Where It Lives

core/views/console/settings/locale_settings.php
    public/assets/js/global.js

How To Create a Protected Select

  1. Render the current value inside .input-protector-display.
  2. Render your <select> inside .input-protector-edit-mode d-none.
  3. Add an edit button that calls toggleInputProtector(this).
  4. Submit the form normally; the select value is sent when the user edits it.

Example (Timezone)

<div class="input-protector-wrapper">
        <button type="button" class="small-icon-btn" onclick="toggleInputProtector(this)">
            <i class="bi bi-pencil-fill"></i>
        </button>

        <div class="input-protector-display d-inline-flex align-items-center gap-2">
            <span class="fw-medium">UTC</span>
        </div>

        <div class="input-protector-edit-mode d-none">
            <select class="form-select form-select-sm" name="timezone">
                <option value="UTC">UTC</option>
                <option value="Asia/Tehran">Asia/Tehran</option>
            </select>
        </div>
    </div>

Notes

  • The timezone list is loaded from public/assets/timezones.json in the PHP view.
  • This pattern is reusable for other settings like calendar type.
  • For the base pattern doc, see input-protector.

Countdown Timer (Client Side Global)

This countdown timer updates any element every second until a target date/time is reached. In most cases you do not need to call any JavaScript manually; you only add HTML attributes.

Where It Lives

public/assets/js/global.js

Quick Start (No Manual JS)

  1. Add class countdown-timer to an element.
  2. Add data-target-datetime in format YYYY-MM-DD HH:mm:ss.
  3. On page load, the timer auto-initializes and updates the element text.

Basic Example

<span class="countdown-timer"
        data-target-datetime="2026-01-01 00:00:00">
        Loading...
    </span>

Attributes

  • data-target-datetime (required) - target datetime string.
  • data-expired-text (optional) - text to show when time is up (default: 00:00:00).
  • data-on-expired (optional) - JavaScript code to execute when expired.

Expired Example

<span class="countdown-timer"
        data-target-datetime="2025-12-31 23:59:59"
        data-expired-text="Event started"
        data-on-expired="console.log('expired')">
        Loading...
    </span>

Manual Initialization (Dynamic Content)

If you insert countdown elements after the page has loaded, initialize them manually:

// Initialize one element
    const el = document.querySelector('.countdown-timer');
    if (typeof initCountdownTimer === 'function') initCountdownTimer(el);

    // Or initialize all countdown timers
    if (typeof initAllCountdownTimers === 'function') initAllCountdownTimers();

Notes

  • The output format automatically shows years/days only when needed (example: 2d 01:05:09).
  • data-on-expired executes via eval. Only use it with trusted code.
  • Auto-initialization happens on DOM ready (DOMContentLoaded).

escapeHtml() (Client Side Global)

escapeHtml(text) converts a string into a safe HTML-escaped string by using textContent.

Where It Lives

public/assets/js/global.js

Quick Start

  1. Get user-facing text that may contain special characters.
  2. Call escapeHtml(text).
  3. Use the returned value inside HTML strings.

Signature

escapeHtml(text)

Examples

escapeHtml('<b>Hi</b>'); // "&lt;b&gt;Hi&lt;/b&gt;"
    escapeHtml('Tom & Jerry'); // "Tom &amp; Jerry"

Notes

  • This helper returns an empty string when text is falsy.
  • Prefer building DOM nodes when possible. Use this when you must build HTML strings.

getFileExtension() (Client Side Global)

getFileExtension(path) returns the lowercase file extension from a path string.

Where It Lives

public/assets/js/global.js

Quick Start

  1. Start with a filename or URL like image.webp.
  2. Call getFileExtension(path).
  3. Use the returned extension for icons or checks.

Signature

getFileExtension(path)

Examples

getFileExtension('a/b/c.pdf'); // "pdf"
    getFileExtension('photo.JPG'); // "jpg"
    getFileExtension('noext');     // ""

Notes

  • This helper splits on . and uses the last segment.
  • If there is no dot, it returns an empty string.

getFileIconByExt() (Client Side Global)

getFileIconByExt(extension, mimeType) returns a Bootstrap Icons class name based on a file extension.

Where It Lives

public/assets/js/global.js

Quick Start

  1. Extract an extension like pdf or mp4.
  2. Call getFileIconByExt(extension).
  3. Use the returned class on an icon element.

Signature

getFileIconByExt(extension, mimeType = '')

Examples

getFileIconByExt('pdf');   // "bi-file-earmark-pdf"
    getFileIconByExt('mp3');   // "bi-file-earmark-music"
    getFileIconByExt('js');    // "bi-file-earmark-code"
    getFileIconByExt('zip');   // "bi-file-earmark-zip"

Notes

  • The function normalizes the extension to lowercase.
  • The mimeType parameter exists, but the current implementation does not use it.
  • If nothing matches, it returns bi-file-earmark.

getFileIcon() (Client Side Global)

getFileIcon(fileType) returns a Bootstrap Icons class name based on a file MIME type string.

Where It Lives

public/assets/js/global.js

Quick Start

  1. Get a MIME type like image/png or application/pdf.
  2. Call getFileIcon(fileType).
  3. Use the returned class on an icon element.

Signature

getFileIcon(fileType)

Example

const iconClass = getFileIcon('application/pdf');
    // iconClass = "bi-file-earmark-pdf"

    // Example usage
    // <i class="bi ${iconClass}"></i>

Notes

  • It checks for common patterns like audio/, video/, pdf, word, excel, zip.
  • If nothing matches, it returns bi-file-earmark.

getFileIconClass() (Client Side Global)

getFileIconClass(extension, isImage) returns a Bootstrap Icons class name based on a file extension, with a special case for images.

Where It Lives

public/assets/js/global.js

Quick Start

  1. Get the file extension (example: pdf).
  2. Decide if it should be treated as an image (true or false).
  3. Call getFileIconClass(extension, isImage) and use it on an icon.

Signature

getFileIconClass(extension, isImage)

Examples

getFileIconClass('pdf', false); // "bi-file-earmark-pdf"
    getFileIconClass('png', true);  // "bi-image"
    getFileIconClass('xyz', false); // "bi-file-earmark"

Notes

  • If isImage is true, it always returns bi-image.
  • If an extension is not in the map, it returns bi-file-earmark.

getISODate() (Client Side Global)

getISODate(dateString) converts a date/time input into a YYYY-MM-DD string.

Where It Lives

public/assets/js/global.js

Quick Start

  1. Start with a date string (example: 2025-12-22 or ISO datetime).
  2. Call getISODate(dateString).
  3. Use the returned YYYY-MM-DD string in your UI.

Signature

getISODate(dateString)

Example

getISODate('2025-12-22T14:30:00Z'); // "2025-12-22"

Notes

  • This uses new Date(dateString) and toISOString().
  • If dateString is empty, it returns an empty string.

getFormatSize() (Client Side Global)

getFormatSize(bytes) converts a byte count into a short human-readable string like 12.5 MB.

Where It Lives

public/assets/js/global.js

Quick Start

  1. Get a file size number in bytes.
  2. Call getFormatSize(bytes).
  3. Show the returned string in your UI.

Signature

getFormatSize(bytes)

Examples

getFormatSize(0);          // "0 Bytes"
    getFormatSize(1024);       // "1 KB"
    getFormatSize(1048576);    // "1 MB"

Notes

  • Units are limited to Bytes, KB, MB, GB.
  • The output is rounded to 2 decimals.

Input Protector (Client Side Global)

The input protector pattern shows a value in a read-only view, and only reveals the real input when the user clicks an edit icon. It is useful for "dangerous" settings to reduce accidental changes.

Where It Lives

public/assets/js/global.js
    public/assets/css/global.css

Quick Start

  1. Wrap your field in .input-protector-wrapper.
  2. Add an edit button that calls toggleInputProtector(this).
  3. Put your read-only view inside .input-protector-display.
  4. Put the real input inside .input-protector-edit-mode and start it as hidden with d-none.

HTML Example

<div class="input-protector-wrapper">
        <button type="button" class="small-icon-btn" onclick="toggleInputProtector(this)">
            <i class="bi bi-pencil-fill"></i>
        </button>

        <div class="input-protector-display d-inline-flex align-items-center gap-2">
            <span class="fw-medium">Current value</span>
        </div>

        <div class="input-protector-edit-mode d-none">
            <input class="form-control form-control-sm" name="my_setting" value="Current value">
        </div>
    </div>

What toggleInputProtector() Does

  • Hides the edit button and the .input-protector-display block.
  • Shows .input-protector-edit-mode by removing d-none and adding d-flex.

Notes

  • This is only a UI helper. The form still submits normally.
  • The default styles for .input-protector-wrapper and .small-icon-btn are in public/assets/css/global.css.
  • The icon uses Bootstrap Icons (bi bi-pencil-fill).

isImageFile() (Client Side Global)

isImageFile(path) returns true when a file path has a common image extension.

Where It Lives

public/assets/js/global.js

Quick Start

  1. Start with a file path or URL.
  2. Call isImageFile(path).
  3. Use the result to choose an image preview or a generic icon.

Signature

isImageFile(path)

Examples

isImageFile('photo.jpg'); // true
    isImageFile('doc.pdf');   // false

Notes

  • Supported extensions include: jpg, jpeg, png, gif, webp, svg, ico, bmp.
  • This check is extension-based only (it does not validate file contents).

windowLogin() (Client Side Global)

windowLogin() opens the login form inside a WindowBox popup.

Where It Lives

public/assets/js/global.js

Quick Start

  1. Add a button or link on your page.
  2. Call windowLogin() on click.
  3. The function closes all open WindowBox windows and opens the login window.

Example

<button type="button" class="btn btn-primary" onclick="windowLogin()">
        Login
    </button>

What It Opens

The URL is built like this:

/{urlLocale}/window/login?return={url}
  • {urlLocale} comes from SITE_DATA.urlLocale.
  • {url} comes from SITE_DATA.url (current page URL).

Notes

  • The window title is translated using await trans('login_to_system').
  • This helper depends on WindowBox (newWinbox()) being available globally.

windowLogout() (Client Side Global)

windowLogout() opens a confirmation window for logging out inside a WindowBox popup.

Where It Lives

public/assets/js/global.js

Quick Start

  1. Add a logout button or menu item.
  2. Call windowLogout() on click.
  3. The function closes all open WindowBox windows and opens a confirm window.

Example

<button type="button" class="btn btn-outline-danger" onclick="windowLogout()">
        Logout
    </button>

What It Opens

The URL is built like this:

/{urlLocale}/window/confirm?act=logout&return={url}
  • {urlLocale} comes from SITE_DATA.urlLocale.
  • {url} comes from SITE_DATA.url (current page URL).

Notes

  • The window title is translated using await trans('logout').
  • This helper depends on WindowBox (newWinbox()) being available globally.

Password Toggle (Client Side Global)

This part of the global client-side script provides togglePasswordVisibility(), a small UI helper to show or hide a password field.

Where It Lives

public/assets/js/global.js

Quick Start

  1. Create a password input with an id (example: password).
  2. Create an icon element with id password_icon (same id + _icon).
  3. Call togglePasswordVisibility('password') when the user clicks your toggle button.

Signature

togglePasswordVisibility(inputId)

What It Does

  • Switches the input between type="password" and type="text".
  • Swaps icon classes on {inputId}_icon between bi-eye and bi-eye-slash.

Example

<div class="input-group">
        <input type="password" id="password" class="form-control">
        <button type="button" class="btn btn-outline-secondary" onclick="togglePasswordVisibility('password')">
            <i id="password_icon" class="bi bi-eye"></i>
        </button>
    </div>

Notes

  • This helper expects Bootstrap Icons classes bi-eye and bi-eye-slash.
  • If the input element or icon element is missing, the toggle will not work.

toast() Notifications (Client Side Global)

toast(text, type, duration) shows a small message on the page for a short time. If a toast is already visible, it is replaced.

Where It Lives

public/assets/js/global.js

Quick Start

  1. Call toast('Saved') after an action.
  2. Optionally set the type to change the toast class.
  3. Optionally set duration (milliseconds) to control how long it stays.

Signature

toast(text, type = 'default', duration = 3000)

Parameters

  • text - message text (string).
  • type - toast style key (string). If not default, the element gets class toast-{type}.
  • duration - time to show the toast (number, ms).

Examples

// Default toast for 3 seconds
    toast('Saved');

    // "success" styling for 4 seconds
    toast('Updated successfully', 'success', 4000);

    // Error toast
    toast('Something went wrong', 'danger', 6000);

Toast Types

The type parameter becomes a CSS class: toast-{type}.

The default CSS in public/assets/css/global.css includes these variants:

  • default (no extra class)
  • success (class: toast-success)
  • error (class: toast-error)
  • warning (class: toast-warning)
  • info (class: toast-info)

Translations

toast() does not translate keys by itself. If you want translated text, translate first and pass the final string:

toast(await trans('toast_success'), 'success');

Toast On Page Load (From Backend)

The main layout can show a toast after redirect by placing attributes on the <body>. On DOMContentLoaded, global.js reads them and calls toast().

<body data-toast-text="success" data-toast-type="success">

Behavior:

  • If data-toast-caller="addon", the text is used as-is.
  • Otherwise, global.js calls trans('toast_' + dataToastText) and passes the result to toast().

Notes

  • This helper creates one .toast-container and one active .toast-message element.
  • Showing a new toast removes the previous one and resets its timeout.
  • Make sure your CSS defines styles for .toast-container, .toast-message, and optional .toast-* types.
  • The message is set via textContent (no HTML rendering).

trans() Translation Helper (Client Side Global)

This part of the global client-side script provides trans(), an async helper that returns a translated string from a dictionary.

Where It Lives

public/assets/js/global.js

Quick Start

  1. Call await trans('your_key') inside an async function.
  2. Optionally pass addon to select a specific dictionary.
  3. Use the returned string in your UI.

Signature

await trans(key, addon = '')

Parameters

  • key - dictionary key (string).
  • addon - which dictionary to read from (string).

Dictionary Selection

  • addon = '' - main dictionary (default).
  • addon = 'custom' - custom dictionary.
  • addon = 'my_addon' - addon dictionary.

Fallback Behavior

If the key does not exist in the selected dictionary, trans() returns the key itself.

Examples

// Inside async code
    const ok = await trans('ok');
    toast(ok, 'success');

    // From an addon dictionary
    const label = await trans('settings_title', 'my_addon');

Notes

  • trans() is asynchronous, so use await (or .then()).
  • This helper only reads dictionaries; it does not create keys for you.

View Preference (Client Side Global)

The view preference helpers store and read a console view mode (like grid or list) using localStorage.

Where It Lives

public/assets/js/global.js

Quick Start

  1. Choose a preference type name (example: media, files).
  2. Read the saved value using getViewPreference(type).
  3. Save a new value using setViewPreference(type, view).

Storage Key

The key format in localStorage is:

console_{type}_view

Signatures

getViewPreference(type)
    setViewPreference(type, view)

Example

// Load
    const view = getViewPreference('media'); // "grid" (default)

    // Save
    setViewPreference('media', 'list');

Notes

  • If localStorage is unavailable (or throws), the getter returns grid.
  • The setter silently ignores storage errors.

Part 11: End-to-End Addon Example

This is a canonical example that ties everything together. It is intentionally complete enough to serve as a template for both humans and AI.

Overview

Example addon name: tasks. It adds a basic task manager with:

  • Console tab for admins
  • Endpoint for listing tasks (JSON)
  • Controller action for saving tasks
  • Window form for add/edit
  • Optional public layout for visitors

Folder Structure

core/addons/tasks/
    manifest.php
    languages/
        en_us.json
    controllers/
        install.php
        item-save.php
    migrations/
        mysql/1/create_table_tasks_items.sql
        sqlite/1/create_table_tasks_items.sql
    views/
        console/
        _config.php
        _permissions.php
        items.php
        endpoints/
        list-items.php
        windows/
        item-form.php
        layouts/
        tasks.php
    

manifest.php

<?php
    return [
        "title" => "tasks",
        "description" => "tasks_description",
        "version" => "1.0.0",
        "db_version" => 1,
        "requires_core" => ">=1.0.0",
        "requires_addons" => [],
    ];
    

Language file

File: core/addons/tasks/languages/en_us.json

{
    "tasks": "Tasks",
    "tasks_description": "Simple task manager",
    "tasks_items": "Items",
    "tasks_new": "New Task",
    "tasks_save": "Save",
    "tasks_title": "Title",
    "tasks_status": "Status"
    }

Installer + migrations

Installer: core/addons/tasks/controllers/install.php

<?php
    if (!hp_verify_permission('install_addons')) {
        $response = ['success' => false, 'message' => 'Not authorized'];
        $_SESSION['ui_alert'] = 'error';
        return;
    }

    $db = Database::getInstance();
    $pdo = $db->getPdo();
    $dbType = $db->getType();
    $addonName = basename(dirname(__DIR__));
    $addonPath = BASE_PATH . '/core/addons/' . $addonName;

    try {
        $migrationsPath = $addonPath . '/migrations/' . $dbType;
        if (is_dir($migrationsPath)) {
            $versionFolders = [];
            foreach (scandir($migrationsPath) as $dir) {
                if ($dir !== '.' && $dir !== '..' && is_dir($migrationsPath . '/' . $dir) && is_numeric($dir)) {
                    $versionFolders[] = (int)$dir;
                }
            }
            sort($versionFolders, SORT_NUMERIC);
            foreach ($versionFolders as $version) {
                $sqlFiles = glob($migrationsPath . '/' . $version . '/*.sql');
                sort($sqlFiles);
                foreach ($sqlFiles as $sqlFile) {
                    $sql = file_get_contents($sqlFile);
                    if ($sql === false) {
                        throw new Exception('Failed to read SQL file: ' . $sqlFile);
                    }
                    $pdo->exec($sql);
                }
            }
        }

        $response = ['success' => true, 'message' => 'Addon installed'];
        $_SESSION['ui_alert'] = 'success';
    } catch (Exception $e) {
        hp_log($addonName . ' install error', ['error' => $e->getMessage()], 'ERROR');
        $response = ['success' => false, 'message' => 'Installation failed'];
        $_SESSION['ui_alert'] = 'error';
    }
    

Migration example (MySQL):

CREATE TABLE IF NOT EXISTS `tasks_items` (
    `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
    `title` VARCHAR(190) NOT NULL,
    `status` ENUM('open','done') NOT NULL DEFAULT 'open',
    `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`)
    );

Migration example (SQLite):

CREATE TABLE IF NOT EXISTS tasks_items (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    status TEXT NOT NULL DEFAULT 'open' CHECK(status IN ('open','done')),
    created_at TEXT NOT NULL DEFAULT (datetime('now'))
    );

Console permissions

File: core/addons/tasks/views/console/_permissions.php

<?php
    return [
        "tasks_console_access",
        "tasks_view",
        "tasks_create",
        "tasks_edit",
        "tasks_delete",
        "tasks_settings_update"
    ];
    

Use the same keys across console tabs, endpoints, controllers, and windows with hp_verify_permission().

Console config (tabs)

File: core/addons/tasks/views/console/_config.php

<?php
    return [
        "title" => "tasks",
        "tabs" => [
            [
                "file" => "items",
                "name" => "tasks_items",
                "icon" => "bi bi-list-check",
                "hidden" => (!hp_verify_permission('tasks_view') ? 1 : 0)
            ],
            [
                "file" => "settings",
                "name" => "settings",
                "icon" => "bi bi-gear",
                "hidden" => (!hp_verify_permission('tasks_settings_update') ? 1 : 0)
            ]
        ]
    ];
    

Console view

File: core/addons/tasks/views/console/items.php

<?php if (!hp_verify_permission('tasks_view')) { return; } ?>
    <div class="p-3">
    <div class="d-flex justify-content-between align-items-center mb-3">
        <h5 class="mb-0"><?php echo hp_t('tasks_items', 'tasks'); ?></h5>
        <button class="btn btn-primary" onclick="windowBox('/' + SITE_DATA.urlLocale + '/tasks/window/item-form')">
        <?php echo hp_t('tasks_new', 'tasks'); ?>
        </button>
    </div>
    <div id="tasks_items_list"></div>
    </div>

    <script>
    fetch('/' + SITE_DATA.urlLocale + '/tasks/endpoint/list-items')
    .then(r => r.json())
    .then(data => {
        if (!data.success) return;
        const list = document.getElementById('tasks_items_list');
        list.innerHTML = data.items.map(item => `<div class="py-1">#${item.id} - ${item.title}</div>`).join('');
    });
    </script>

Endpoint (read)

File: core/addons/tasks/views/endpoints/list-items.php

<?php
    if (!hp_verify_permission('tasks_view')) {
        return ['success' => false, 'message' => 'Not authorized'];
    }

    $items = hp_db_all("SELECT id, title, status FROM tasks_items ORDER BY id DESC LIMIT 200", []);
    return [
        'success' => true,
        'items' => $items
    ];
    ?>

Controller (action)

File: core/addons/tasks/controllers/item-save.php

<?php
    $response = ['success' => false, 'message' => ''];
    if (!hp_verify_permission('tasks_create')) {
        $response['message'] = 'Not authorized';
        return;
    }

    $title = hp_sanitize($data['title'] ?? '');
    if ($title === '') {
        $response['message'] = 'Title is required';
        $response['error'] = ['code' => 'VALIDATION', 'fields' => ['title' => 'Required']];
        return;
    }

    $id = hp_db_insert("INSERT INTO tasks_items (title, status) VALUES (?, 'open')", [$title]);
    $response = ['success' => true, 'message' => 'Saved', 'data' => ['id' => (int)$id]];
    ?>

Window (form)

File: core/addons/tasks/views/windows/item-form.php

<?php if (!hp_verify_permission('tasks_create')) { return; } ?>
    <div class="p-3">
    <h6 class="mb-3"><?php echo hp_t('tasks_new', 'tasks'); ?></h6>
    <form method="post" action="/<?php echo $siteContext['urlLocale']; ?>/tasks/action/item-save">
        <input class="form-control mb-2" name="title" placeholder="Title">
        <button class="btn btn-primary" type="submit"><?php echo hp_t('tasks_save', 'tasks'); ?></button>
    </form>
    </div>

Content layout (public)

File: core/addons/tasks/views/layouts/tasks.php

<?php
    $items = hp_db_all("SELECT id, title FROM tasks_items WHERE status = 'open' ORDER BY id DESC LIMIT 20", []);
    ?>
    <div class="container py-4">
    <h3>Tasks</h3>
    <ul>
        <?php foreach ($items as $item) { ?>
        <li>#<?php echo $item['id']; ?> - <?php echo $item['title']; ?></li>
        <?php } ?>
    </ul>
    </div>

Advanced add-ons (optional)

Once the base addon works, you can extend it with advanced features:

  • Dashboard card: add core/addons/tasks/views/dashboard/default.php and optional data.php for quick stats.
  • Toolbar icon: enable in manifest and add a toolbar button in the addon console.
  • Menu listing: create support/helpers.php with tasks_menu_list($locale) to add menu builder entries.
  • Notification count: add a helper that returns unread counts for the toolbar badge.
  • Queue worker handlers: if you need background jobs, add handlers in the addon and register jobs in the queue.

Guides & Resources

Explore addons, read usage guides, or start developing your own addon

Build an Addon
If you want to build a new feature, this is the right path: addons are the primary development unit in Horuph and grow without touching the Core.
Learn More
Development Documentation
Technical details for building addons and developing services.
Learn More

Need More Help?

For complete developer documentation and advanced guides, visit the documentation center.