Addon Development
This guide applies to Horuph Studio v1.0.0.
Contents
- Part 1: Structure Help
- Part 3: Addon Basics
- Part 4: Addon Views
- Part 5: Addon Other Topics
- Part 6: Addon Advanced Topics
- Part 7: Addon Services & Integrations
- Part 8: Guidelines
- Part 9: Core Server-side
- Part 10: Core Client-side
- Part 11: End-to-End Addon Example
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.sqlcore/addons/newsletter/migrations/*/*.sqlcore/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 hereerror(optional): array withcode(string) and optionalfields(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
- Create your folder:
core/addons/my_addon/. - Add manifest.php and icon.png.
- Add language packs for UI text (minimum required:
[addon]and[addon]_descriptionkeys matching your manifest). - Set up data with migrations (installer optional).
- Build management UI with console views (and optional dashboard card).
- Build visitor UI with layouts and optional blocks.
- 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
- Developer Mode
- Addon Manifest
- Addon Installer
- Addon Assets
- Language Packs
- Addon Helpers
- Addon Icon
- Addon Console Permissions
- Error Handling Patterns
- Testing & Debugging
Content (Layouts + Blocks)
Console (Management UI)
- Addon Consoles + List Design, Settings Design, Tab Toolbar
- Dashboard Cards + Design Guide
- Windows + Form Design Guide
JSON Routes (Fetch vs Action)
- Endpoints (Fetch JSON)
- Controllers (Do Actions) - See also Activity Log
- API (External JSON)
- Error Handling Patterns - How to handle errors consistently
- Testing & Debugging - Practical checklist for addon dev
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:
- Console List with Pagination - How to build data tables with search, filters, and pagination.
- Console Settings - How to build settings pages with form groups and validation.
- Console Tab Toolbar - How to add action buttons and filters above console tabs.
- WindowBox Form - How to build forms inside popup windows.
- Dashboard Card - Complete guide for dashboard stat cards.
- Toolbar Icon Bar - How to style toolbar buttons and icons.
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
- WindowBox
- HTML Preserve Editor
- DateTime Picker
- Signature Pad Input
- File Upload Component
- Confirmation Windows
- Relative Time
- Console Pagination
Client-side global helpers:
- trans()
- toast()
- Password Toggle
- Input Protector
- View Preference
- getFormatSize()
- getISODate()
- escapeHtml()
- getBasename()
- getFileExtension()
- getFileIcon()
- getFileIconByExt()
- getFileIconClass()
- isImageFile()
- Countdown Timer
- Console Settings Timezone
- windowLogin()
- windowLogout()
Bootstrap Context
- Bootstrap context
- urlData
- URLs and routing context
- Locale and UI helpers
- Configuration flags
- Authentication context
- Addons and extension points
- Request parameters
- Meta and project info
Database
- hp_table_exists
- hp_db_all
- hp_db_one
- hp_db_exec
- hp_insert
- hp_db_date_condition
- Database overview
- Database configuration
- Database::getInstance
- Database::getType and Database::getPdo
- Database::query
- Database::select and Database::selectOne
- Database::insert, update, and delete
- Database::count and Database::exists
- Database transactions
- Database::getLastError
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
- Addon Manifest
- Addon Installer
- Update / Migration Workflow
- Uninstall / Cleanup Flow
- Packaging & Distribution
- Addon Language Packs
- Addon Assets
- Addon Icon
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:
- Go to Global Settings in the console
- Find the "Developer Mode" label
- Click on the label 10 times (this is an easter egg to prevent accidental activation)
- After 10 clicks, the developer mode toggle will appear
- Check the toggle to enable developer mode
- 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_addonsdatabase table - Only addons with
is_enabled = 1are 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 withaddonandversionkeys.toolbar(optional): Declares the toolbar icon placement in Horuph CMS. You can setglobal,local, orbartotruedepending 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
- Create
core/addons/my_addon/manifest.php(or edit the existing file). - Return an array with at least
title,description,version, andrequires_core. Keepversionin MAJOR.MINOR.PATCH format. - Add
db_versionwhen your addon runs migrations and needs schema tracking. - List dependencies inside
requires_addonswhen you rely on another addon. You can useaddon => versionpairs or the array-of-arrays format. - Declare toolbar visibility using the
toolbararray. Setglobal,local, orbartotruewhen you want icons in those sections. - Use
preserve_on_updateif you ship files that should survive addon updates (relative paths from the addon root).
Tips
- Always bump the manifest
versionwhenever you add features, fix bugs, or change the database schema. - Keep translation keys (for
titleanddescription) consistent with the language files inside your addon. Your language packs must include[addon](matchingtitle) and[addon]_description(matchingdescription) as minimum required keys. See Language Packs for details. - When an addon has no dependencies, set
requires_addonsto 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
- Create
controllers/install.phponly if you need custom setup logic. - Keep it idempotent: it can run during install or update.
- Do not run migrations here; Horuph runs them automatically.
- 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 usesAUTOINCREMENT. - Integer Primary Key: MySQL uses
INT(11) UNSIGNED AUTO_INCREMENT, SQLite usesINTEGER PRIMARY KEY AUTOINCREMENT. - Text Fields: MySQL uses
VARCHAR(n)orTEXT, SQLite usesTEXT. - DateTime: MySQL uses
DATETIME, SQLite usesTEXT. - Boolean: MySQL uses
TINYINT(1), SQLite usesINTEGER. - Indexes: MySQL can define indexes inside CREATE TABLE, SQLite usually needs separate
CREATE INDEXstatements.
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 EXISTSin 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:
- Place SQL files inside version folders:
migrations/<db>/1/,migrations/<db>/2/, ... - Version folders are executed in numeric order (1, 2, 3...).
- Within each version folder, SQL files are executed alphabetically.
- Set
db_versioninmanifest.phpto 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:
- Creates a backup ZIP of the addon folder.
- Creates SQL backups based on migration files.
- Runs optional
controllers/uninstall.php(if present). - Drops addon tables (based on
create_table_*.sqlfile names). - Removes addon records from
system_addonsandsystem_addons_deps. - 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>.jsonwith[addon]and[addon]_descriptionkeysmigrations/<db>/<version>/*.sqlif the addon uses a databaseicon.png(recommended for console UI)
Versioning
- Use semantic versioning:
major.minor.patch. - Update
manifest.phpversionon any release. - Update
db_versionwhen 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_updateinmanifest.phpfor 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
- Pick the locale code. Use lowercase language + underscore + lowercase country (example:
en_us,fa_ir). - Create
languages/<locale>.jsonif it does not exist yet. - 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 manifesttitlefield)[addon]_description- The addon description (matches manifestdescriptionfield)
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
- Create
assets/jsand/orassets/cssinside your addon. - Drop one of the reserved filenames listed below into those folders.
- 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.phtmlandfooter.phtmltemplates. Access these throughConsole → Settings → Header TagsandConsole → 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
- Site Blocks (Addon)
- Addon Consoles
- Addon Dashboard Cards
- Addon Page Endpoints
- Addon APIs
- Addon Windows
- Shared files (Addon)
- Addon Header Files
- Addon Footer Files
- Addon Root Files
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: Usepageorsection.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.phpfast: only set what you need (title, description, parents, url_path). - For unknown URLs, return an empty value from
_config.phpto keep the 404 behavior. - Try to keep
default.phpsimple: 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
- Create
core/addons/my_addon/views/blocks/{block-name}/. - Create
_config.phpto describe the block in the UI (title + description). - Create
default.phpto render the block on the site. - Optionally create
settings.phpif 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 fromsystem_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, andicon. Thenameshows on desktop; theiconshows on mobile. - Use the optional
hiddenflag (truthy value) to hide tabs until a condition is met. You can usehp_verify_permission()to control tab visibility based on user permissions. - Place any script URLs in the
scriptsarray 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$siteContextin 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
$siteContextdata - 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
owneruser 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 tocore/addons/<addon>/support/verify_permission.php. This allows addons like ausersaddon 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 consolemy_addon_reports_view- View reports tabmy_addon_reports_export- Export reports functionalitymy_addon_settings_edit- Edit settingsmy_addon_items_create- Create new itemsmy_addon_items_delete- Delete items
Real-world examples from existing addons:
contents_view,contents_create,contents_edit,contents_delete- Used in Contents addoncloudflare_requests_view,cloudflare_requests_delete- Used in Cloudflare addonusers_view,users_create,users_edit,users_delete- Used in Users addonusers_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
- Create
core/addons/my_addon/views/console/(if it does not exist). - Add
_config.phpwith your tab definitions, icons, and scripts. - Create one PHP file per tab (for example,
dashboard.php,reports.php,settings.php). - Optionally add
_permissions.phpand use those keys in your tab files before sensitive actions. - 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 => 1so 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.phpfile 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:
- Gather any counts or timestamps you want to show (wrap database access in
try/catchso errors do not break the dashboard). - Print your stat rows inside a parent
<div>, usingstat-item,stat-label, andstat-valueclasses for each line. - 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-timeplusdata-datetimefor timestamps, and wrap optional blocks in a container that can toggled-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 likedata-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_cardscontainsaddon/my_addon/default. Respect that setting by keeping your IDs stable. - Users must have the
my_addon_console_accesspermission (defined in your addon permissions file) to see the card. Use the same key when you need to check permissions insidedefault.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
- Create
core/addons/my_addon/views/dashboard/if it does not exist. - Build
default.phpwith your stat rows, translated labels, andmt-autofooter links. - Add
data.phpwhen you need live updates, returning thetexts/texts_data/classesarrays. - Visit the dashboard console to verify the card shows the addon icon and name, then watch the network calls to confirm
/<locale>/endpoint/dashboard-dataupdates 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
$siteContextby addon bootstrap files
Rules Inside Endpoint Files
- Never call
header(),exit,die,echo, orprintinside 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,
$datais 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: booleanmessage: string (optional but recommended on errors)data: array/object/null (your payload)error(optional): object withcodeand optionalfieldsfor 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
- Error Handling Patterns - Complete guide for handling errors, validation, and database errors in endpoints
- hp_log - Logging errors and debug information
- hp_verify_permission - Permission checking
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.phpcore/classes/Helpers.phpcore/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
- Create
core/addons/my_addon/views/api/. - Add a PHP file (example:
test.php). - Return an array at the end of the file.
- 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_encodeproduces 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
$siteContextis 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
- Create
core/addons/my_addon/views/windows/. - Add a PHP file (example:
entry-form.php). The file should output only the window body HTML (no<html>,<head>, or layout wrapper). - Open it with
newWinbox()by pointingurlto 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
idandtoken(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 →
confirmwindow 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
onclosecallback to refresh data or perform cleanup when a window closes. - Always use
SITE_DATA.urlLocalewhen 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
- Create
core/addons/my_addon/views/shared/. - Add a PHP file (example:
download.php). - Visit it using
/shared/my_addon/download. - 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
403when 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
- Create a file named
header.phpin your addon folder - Write PHP code that outputs HTML tags using
echoorprint - 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.phpif 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
Addon Footer Files
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
- Create a file named
footer.phpin your addon folder - Write PHP code that outputs HTML tags using
echoorprint - 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.phpif 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
- Create a folder named
rootinside your addon folder - Create a PHP file with the desired filename plus
.phpextension - For example, to serve
/service-worker.js, createroot/service-worker.js.php - Write PHP code that outputs the desired content
- 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.phpto 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_PRINTandJSON_UNESCAPED_SLASHESflags 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
.phpextension, even if they output non-PHP content - The URL path is derived from the filename by removing the
.phpextension - 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
- Error Handling Patterns
- Testing & Debugging
- Addon Helpers
- Addon Console Permissions
- Addon Render (Page-Build Pipeline)
- Addon Toolbar Icons
- Addon Notification Counts
- Addon Sitemaps
- Addon Queue Worker Handlers
- Addon Bootstrap (Global Context)
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-saveloadscore/addons/<addon_name>/controllers/content-save.php. - Request body can be form data (
$_POST) or JSON (decoded into$databy the action router).
Controller Template
- Create the file inside your addon’s
controllersdirectory. - Read/sanitize input, run your logic, and store the result in a
$responsearray. - Never echo output or set headers; the router finishes the response for you.
Response Rules
$responsemust 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$responseas 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:
- Define redirect URL at the top of your controller (before the try block) so it's available for both success and failure cases:
- Always include
redirectin both success and failure responses when using windowbox forms or when you want to redirect users after actions: - 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)
<?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 ...
}
// 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';
- 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
$redirectUrlat 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.codefor machine handling (example:FORBIDDEN,NOT_FOUND,VALIDATION). - Use
error.fieldsfor 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
- Error Handling Patterns - Complete guide for handling errors, validation, and database errors
- Activity Log - Automatic logging of controller actions
- hp_log - Logging errors and debug information
- hp_verify_permission - Permission checking in controllers
Dos and Don'ts
- ✅ Use
$responsefor every exit path (including validation errors). - ✅ Always include
successandmessagein 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
redirectin 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
$responsein exception handlers (catch blocks). - ❌ Do not echo JSON manually or send headers; the router handles it.
- ❌ NEVER exit.
- ❌ Do not skip
successandmessageeven when redirecting — activity logging happens before redirect. - ❌ Don't forget to set
redirectin 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
$responseand optionallyredirect; the action router returns JSON or redirects. - Endpoints: set
$response; do not echo JSON or set headers. - HTTP status: endpoints use
$siteContext['httpStatus']forhttp_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 includeerror.fieldswith 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
ERRORfor failures,WARNINGfor 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.fieldsto provide field-specific messages.
Related Documentation
- Addon Controllers - How to create controller actions
- Addon Endpoints - How to create endpoints
- hp_log - Logging errors and debug information
- hp_verify_permission - Permission checking
- Database::getLastError - Getting database error details
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
- Create the
supportfolder (if it does not exist) inside your addon. - Add a
helpers.phpfile. - Define your helper functions directly in that file. Keep names unique to avoid collisions (prefix with the addon name if needed).
- 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 thefunction_existscheck. - 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_nameinstead offormat_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
- Enable Developer Mode so new addons load without install.
- Open your addon console:
/{locale}/addon-console/<addon>. - Test endpoints directly in the browser or DevTools:
/{locale}/<addon>/endpoint/<name>. - Test controllers via form submit or fetch POST:
/{locale}/<addon>/action/<name>. - Verify JSON shape:
success+messagealways present.
Where to See Errors
- Console error logs: Console -> Dashboard -> Error Logs (reads
storage/logs/horuph.log). - Activity logs: Console -> Logs (if
save_logsis 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/messageand 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
- Create
core/addons/my_addon/views/console/_permissions.phpinside your console folder. - Return an array of permission keys as strings.
- Follow the naming convention:
addon_name_action_name(lowercase with underscores). - 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
owneruser 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.phpfile 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.phpwith 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
- Addon Consoles - Learn how to create console tabs and use permissions within them
- hp_verify_permission - Documentation for the permission checking helper function
- Verify Permission - Advanced permission management patterns
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 intocore/controllers/action.php. - Does not run for endpoints (
.../endpoint/...) because render exits intocore/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.phpis 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
- Create
core/addons/<addon_name>/views/render.php. - Write safe logic that reads/modifies
$siteContext(no HTML output). - 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.localwhen 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.globalfor 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-buttonclass 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> <?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
- 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).
- Update
manifest.phpwith the appropriatetoolbarflags. - If you enabled
toolbar.bar, createviews/toolbar/icon_bar.phpand render your custom button. - Refresh the admin toolbar and verify that the icon appears in the correct section, respects permissions, and opens the expected window or link.
- 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.phploops 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.jscalls 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-badgeor the global.addons-total-badgegets 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()
- Name the function exactly after your addon slug followed by
_notifications_count(for example,my_addon_notifications_count()). - 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).
- When the addon has no notifications, return
0. Avoid returningnullor strings. - Wrap database access in
try/catchor 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
- Create the
sitemapsfolder and both files inside your addon. - List every sitemap name in
sitemaps.php. - Inside
sitemap.php, check$requestPartsto know which sitemap was requested and return the matching URLs. - 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-mandcontent-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 functionmy_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
- Create
core/addons/my_addon/workers/handlers.php. - Define a global PHP function that accepts one argument: the payload array.
- Return an array with
success(boolean) andmessage(string). - 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
successis false, the worker retries untilmax_attemptsis reached.
Example Job Type
Keep your job_type addon-scoped (first segment is your addon slug):
my_addon,do_work
Notes
- For how workers run (Fire Worker vs Queue Worker), see queue-workers.
- For the enqueue API, see hp_queue_job.
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.phpafter 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
$siteContextkey exists in every runtime; always guard withisset()/!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
- Create
core/addons/<addon_name>/views/bootstrap.php. - Write to
$siteContextusing a namespaced key (recommended: your addon slug). - 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
- Local Settings Inputs (Addon)
- Password Settings (Addon)
- Verify Permission (Permission Manager Addon)
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
- Create
input_fields.phpto register your field type. - Create
input_[type].phpto render the input in forms. - Create
input_[type]_save.phpto process the value when saving. - Create
input_[type]_view.phpto 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-saveand must add your values into the$configarray before it is saved.
The saved values are written into public/languages/{locale}/config.php.
How to Create
- Create
core/addons/my_addon/views/console/locale_settings/. - Add
inputs.phpand render your fields using normal Bootstrap markup. - Add
inputs_save.php, read submitted values from$data, sanitize them, and write them into$config. - 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
- Create
core/addons/my_addon/support/password_settings.php. - Return an array with your password rules.
- 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
trueorfalse.
Folder Layout
core/
addons/
my_addon/
support/
verify_permission.php
How to Create
- Create
core/addons/my_addon/support/verify_permission.php. - Write your permission logic using
$permissionand$siteContext. - Return a boolean.
- Use
hp_verify_permission('some_permission')anywhere you need permission checks.
Inputs Available
$permission: the permission key passed intohp_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()incore/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:
$valueis an array of the remaining path parts (example:['arg1', 'arg2']).$responseis 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.phpbefore 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.
- Make sure the websockets addon is installed in
core/addons/websockets/ - Configure the WebSocket server settings in the console (Settings page)
- Set the
WS_SECRETenvironment variable for token security - 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
- Create a folder named
websocketsinside your addon folder - Create a file named
allow_list.phpinside thewebsocketsfolder - 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 betrueto enable connectionwindow.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:orwss:(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 serverWebSocketClient.disconnect()- Disconnect from the serverWebSocketClient.subscribe(topic)- Subscribe to a new topicWebSocketClient.unsubscribe(topic)- Unsubscribe from a topicWebSocketClient.publish(message)- Publish a message to the current topicWebSocketClient.isConnected()- Check if currently connectedWebSocketClient.getTopic()- Get the current topic name
Part 8: Guidelines
- Writing Structure Help Documentation
- WindowBox Form Design Guide
- Toolbar Icon Bar Design Guide
- Dashboard Card Design Guide
- Console with Unlimited Input Fields Design Guide
- Console Tab Toolbar Design Guide
- Console Settings Design Guide
- Console List with Pagination Design Guide
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_addonso 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-columnon the wrapper so toolbars stay sticky. - Use the system datepicker for date/time fields (
h-datetime-pickerwith data attributes). - Always include a
returnparameter 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-buttonclass for styling. - Use
SITE_DATA.urlLocalein 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-1spacer- Clear Filters button
- Filters
- Search input (right)
Rules
- Use
flex-nowrapso toolbar items do not wrap. - Do not use
btn-smorform-select-smin 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.phpfor addon settings. - Use controller actions to validate and save.
- Return
success,message, and optionalredirect.
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
ConsolePaginationfrompublic/assets/js/console-pagination.js. - Use
escapeHtml()frompublic/assets/js/global.jsfor 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-nowrapto avoid wrapping. - Avoid
btn-smandform-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)
- Bootstrap: Addons and Extension Points
- Bootstrap: Authentication Context
- Bootstrap: Configuration Flags
- Bootstrap: Meta and Project Info
- Bootstrap: Request Parameters
- Bootstrap: Locale and UI Helpers
- Bootstrap: URLs and Routing Context
- urlData (Server Side)
- Activity Log (Server Side)
- Hooks (Server Side)
- Queue Workers (Server Side)
- Database Class Overview
- Database Configuration
- Database::getInstance
- Database::getType and Database::getPdo
- Database::getLastError
- Database::query
- Database::select and Database::selectOne
- Database::count and Database::exists
- Database::insert, update, and delete
- Database Transactions
- hp_add_user
- hp_create_upload
- hp_create_urlmap_input
- hp_date
- hp_date_custom
- hp_db_all
- hp_db_date_condition
- hp_db_exec
- hp_insert
- hp_db_one
- hp_get_setting
- hp_img_url
- hp_log
- hp_queue_job
- hp_save_urlmap
- hp_set_setting
- hp_t
- hp_table_exists
- hp_temp_folder
- hp_t_locale
- hp_url
- hp_verify_permission
- hp_client_ip
- hp_copy_directory
- hp_decode_base32
- hp_delete_directory
- hp_encode_base32
- hp_export_config
- hp_file_icon
- hp_format_bytes
- hp_format_size
- hp_html_to_text
- hp_query_part_change
- hp_sanitize
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
$siteContextdata is available - Endpoints - Full
$siteContextaccess - Layouts - Complete
$siteContextavailable - Windows - Full
$siteContextaccess - Blocks - Complete
$siteContextavailable - Console tabs - All
$siteContextdata 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.
- URLs and routing context - Use
urlData,locale,urlLocale, andurl_pathto build links and detect the current section. - Locale and UI helpers - Use direction/alignment fields and dictionaries for RTL/LTR and translations.
- Configuration flags - Use debug/theme/log/heartbeat flags that bootstrap loads from
horuph.php. - Authentication context - Use
authenticatedand theuserobject to gate features and show account UI. - Addons and extension points - Use addon manifests, permission manager detection, and addon bootstrap files.
- Request parameters - Use
q,page,tab, andalertfor console and endpoints. - Meta and project info - Use meta defaults, title/description, timezone, calendar type, and project name.
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
urlsaddon 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
- Addon manifest - How manifests are structured.
- Hooks - Registering listeners from addons.
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
- hp_verify_permission - Permission checks for specific actions.
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']-ltrorrtl.$siteContext['alignment']-leftorright.$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 rawREQUEST_URIstring.
How to Use It
- Use
$siteContext['urlData']to decide which page you are on. - Use
hp_url($siteContext['locale'], $urlData)to generate links (never hardcode strings). - 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):
- The router extracts the request segments into
$routerUrlData. - The system detects the locale (using short locale or full locale settings).
$siteContext['url_path']is set toimplode('/', $routerUrlData).- If the
urlsaddon is installed, it can map the request usingurls_url_mapand replace the request with the mappedurl_data(decoded JSON) so the application runs on urlData. - 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
urlsaddon looks for aurls_url_maprecord wherelocalematches andurl_dataequalsjson_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:
- Determines the controller file and action name
- Includes and executes the controller file
- Extracts
successandmessagefrom the controller's$responsearray - Writes an activity log entry before redirecting or returning JSON
- Includes the response status (
successandmessage) 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 clienterror- For structured error informationpage,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-saveorlogout) - 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 parametersdata_keys- Keys from request datadata- 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$responsearray. - Always include
successandmessage: These are required for activity logging to work correctly. - Use meaningful messages: The
messagefield appears in activity logs, so use clear, descriptive messages. - Handle exceptions properly: Wrap your controller logic in try-catch blocks and always set
$responsein catch blocks. - Don't skip response on redirects: Even when redirecting, you must provide
successandmessagebecause logging happens before the redirect.
Related Documentation
- Addon Controllers - How to create controller actions
- hp_verify_permission - Permission checking in controllers
- hp_log - Logging errors and debug information
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.registeredorpage.render.before. - Keep hook listeners fast. Hooks can run on every request.
- Wrap risky logic in
try/catchso one addon does not break the whole request. - Use priority to control order:
1for early,100for 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
- Create a job handler function in your addon (
core/addons/<addon>/workers/handlers.php). - Enqueue a job using
hp_queue_job()with a job type likemy_addon,my_handler. - 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 functionmy_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().
- Enable heartbeat in
horuph.phpconfig. - Each request triggers a fast background call to
/core/workers/fire_worker/.... - 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- use127.0.0.1calls 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.
- Start
core/workers/queue_worker.phpas a background process (service/CLI task). - It calls
DoOneJob('queue_worker')repeatedly. - When the queue is empty, it sleeps longer; when busy, it sleeps briefly.
How Jobs Are Picked and Updated
- Only jobs with
status = pendingandavailable_at <= noware executed. - Jobs are ordered by
priority DESC, thenid ASC. - When a job starts, status becomes
processingandattemptsincreases by 1. - On success, status becomes
completed. - On failure, status becomes
pendingagain untilmax_attemptsis reached, then it becomesfailed.
Notes
- The worker updates the system setting
last_heart_beatafter 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(), andtransaction(). - 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()(orhp_db_all/hp_db_one). selectOne()callsselect()withlimit = 1and returnsfalsewhen no row exists.orderByis 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)
- 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(setowneror 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.registeredhook 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
requiredattribute. - showPreview: Toggle preview panel.
- useMediaLibrary: Enables the “Choose from media library” button.
- saveValueAs:
pathortoken(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
useMediaLibraryis 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
blogwhen 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
defaultValueso 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 byglobal-admin.jswhen 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 anddatetime('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
nullso 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
nullifsystem_settingsdoes 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']istrue. If debug mode is disabled, the function returns immediately without writing anything. - Writes to
storage/logs/horuph.log(relative toBASE_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
ERRORfor failures,WARNINGfor concerns,INFOfor important events, andDEBUGfor 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. Usehp_log()instead oferror_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
- Activity Log - Automatic logging of controller actions
- Configuration flags - How to enable debug mode
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 (defaultdefault).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_jobstable exists before inserting. - Logs descriptive errors (including invalid job types or JSON encoding failures).
- Use
available_atto schedule future jobs: pass aDateTimeobject 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
nullto 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
$valueisnull, 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
customforcustom_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, usecustomforcustom_dictionary.json, or provide an addon slug to readcore/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
$urlDatais empty, you still get a locale-prefixed home URL. - If the
urlsaddon finds a matching path inurls_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
trueimmediately when the user is authenticated asowner. - If a permission manager addon is configured (see
$siteContext['permission_manager']), the helper includescore/addons/<addon>/support/verify_permission.phpto evaluate the key. - Returns
falsewhen 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_IPHTTP_X_REAL_IPREMOTE_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
- Creates the destination directory if it doesn't exist, with permissions 0755.
- Gets the real path of the source directory to handle symbolic links correctly.
- Iterates through all files and subdirectories recursively using
RecursiveIteratorIterator. - For each item: creates directories as needed, and copies files to their corresponding paths in the destination.
- 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
- Start with a Base32 string (for example something you stored earlier).
- Call
hp_decode_base32($data). - 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-Zand2-7are 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
- Checks if the path is a valid directory. Returns
falseif not. - Scans the directory contents, excluding
.and..entries. - For each item: if it's a directory, recursively deletes it; if it's a file, deletes it.
- 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
- Decide what data you want to encode (for example a random token).
- Call
hp_encode_base32($data). - 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-Zand2-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 <script>alert(1)</script>'
// 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
nulland it will be converted to an empty string.
Part 10: Core Client-side
- SITE_DATA
- WindowBox (Client Side)
- File Upload Component (Client Side)
- HTML Preserve Editor (Client Side)
- DateTime Picker (Client Side)
- Console Pagination (Client Side)
- Confirmation Windows (Client Side)
- Signature Pad Input (Client Side)
- Relative Time (Client Side)
- getBasename() (Client Side Global)
- Console Settings Timezone Field (Input Protector)
- Countdown Timer (Client Side Global)
- escapeHtml() (Client Side Global)
- getFileExtension() (Client Side Global)
- getFileIconByExt() (Client Side Global)
- getFileIcon() (Client Side Global)
- getFileIconClass() (Client Side Global)
- getISODate() (Client Side Global)
- getFormatSize() (Client Side Global)
- Input Protector (Client Side Global)
- isImageFile() (Client Side Global)
- windowLogin() (Client Side Global)
- windowLogout() (Client Side Global)
- Password Toggle (Client Side Global)
- toast() Notifications (Client Side Global)
- trans() Translation Helper (Client Side Global)
- View Preference (Client Side Global)
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_USorfa_IR - urlLocale (string): Locale prefix used in URLs, e.g.,
enorfa
// 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 includejalaliorislamic
if (SITE_DATA.calendarType === 'jalali') {
// Use Persian calendar formatting
}
Text Direction and Alignment
- direction (string): Text direction, either
ltr(left-to-right) orrtl(right-to-left) - directionRev (string): Reverse of direction. If direction is
ltr, this isrtl, and vice versa - alignment (string): Text alignment, either
leftorright - alignmentRev (string): Reverse of alignment. If alignment is
left, this isright, 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):
trueif the current user is authenticated,falseotherwise
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_DATAis 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.
urlDataandparentDataare 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
- Call
newWinbox()from your page JS (or inlineonclick). - Pass
titleand eitherurlorhtml. - Optionally set
width,maximizable,fullscreen, andonClose.
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 usingfetch().iframe(boolean) - Iftrue, loadurlin 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. Ifmaximizableis 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 withreturn 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)
- Render the component using
hp_create_upload()in your PHP view. - Make sure
public/assets/css/global.cssandpublic/assets/js/global.jsare included on the page. - 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 callshandleFileUpload(this)on change. - A hidden input
{id}_currentthat stores the current value to save (path, token, or a JSON array for multiple). - A wrapper
.file-upload-wrapperwith 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
defaultValueas a path or a 64-character token. - On page load,
initializeFileUploadPreviews()readsdata-default-valueand 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
- Browser-side checks (like
accept) are only UI hints; always validate uploads on the backend. - For media-library specific behavior, see open-file-upload-media-library and insert-file-upload-image.
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
- Include the CSS and JS.
- Add a textarea with class
html-preserve-editor. - 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 ifnameis missing.data-id- stable editor id (used by some helpers like image insert).data-placeholder- placeholder text for the visual editor.data-min-height- default300px.data-max-height- defaultnone.data-auto-height- set to1to make the editor automatically grow with content while maintaining vertical scroll if content exceeds available space.data-mode- toolbar mode, defaultfull(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 inlinestyle="..."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 legacydata-secure-input="false").1- strips inline styles on paste (same as legacydata-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 (default200000).data-max-paste-chars- max characters per paste (default30000).data-max-data-image-bytes- max bytes per embeddeddata:image(default1000000).
<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.HtmlPreserveEditorModesbefore 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
- Add an input with class
h-datetime-picker. - Set a Gregorian value (optional) like
YYYY-MM-DDTHH:mm(orYYYY-MM-DD). - 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-typeoverrides the site default calendar (which comes fromwindow.SITE_DATA.calendarTypewhen available).- Supported display calendars include
gregorian,jalali,hijri,hebrew,buddhist,coptic,ethiopian. chineseis treated as unsupported in the UI and falls back togregorian.- For some non-Jalali calendars, the picker may call the conversion endpoint
/{urlLocale}/date_converter/endpoint/convertfor 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-invalidstyles 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
- Add an info element and a pagination list (
<ul>) with stable ids. - Create a
ConsolePaginationinstance with those ids. - When your API data loads, call
await pagination.update(total, currentPage, totalPages)(make sure your function isasyncor 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 isasyncand usesawait trans()for internationalization. Button labels (Previous/Next) and info text (Showing) are automatically translated using the globaltrans()function. - When calling
pagination.update(), make sure to useawaitif 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.
- Build a URL like
/{urlLocale}/window/confirm?act=logout&return=/en_us/console. - Open it using
newWinbox(). - 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 asreturn_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
actandreturn) are also sent as hidden inputs.
Quick Start (Async Confirm)
This option submits the action with fetch() and expects a JSON response.
- Optionally set a callback in
SITE_DATA.storedActions(a function). - Open
/{urlLocale}/window/confirm-async?act=...usingnewWinbox(). - 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) orall.message(optional) - custom confirmation message text. If not provided, uses defaultconfirm_messagetranslation.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.storedActionsmust 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/confirmorwindow/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(), orprompt()
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)
- Add a hidden input with class
signature-pad-input. - Optionally set
data-heightanddata-placeholder. - 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
requiredto block form submit when the signature is empty. - Add
disabledto 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 helpertrans()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)
- Add class
relative-timeto an element. - Add
data-datetimewith a datetime string. - 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:00or2025-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-datetimeis empty or equalsN/A, it showsN/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 minutemin_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()andupdateRelativeTimes()areasyncfunctions that useawait trans()for internationalization. - If you call
initRelativeTimes()more than once, it will create multiple intervals. Prefer callingupdateRelativeTimes()for dynamic updates. - When calling
formatRelativeTime()directly, make sure to useawait(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
- Start with a path like
/uploads/a/b/file.pdf. - Call
getBasename(path). - 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
pathis 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
- Render the current value inside
.input-protector-display. - Render your
<select>inside.input-protector-edit-mode d-none. - Add an edit button that calls
toggleInputProtector(this). - 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.jsonin 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)
- Add class
countdown-timerto an element. - Add
data-target-datetimein formatYYYY-MM-DD HH:mm:ss. - 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-expiredexecutes viaeval. 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
- Get user-facing text that may contain special characters.
- Call
escapeHtml(text). - Use the returned value inside HTML strings.
Signature
escapeHtml(text)
Examples
escapeHtml('<b>Hi</b>'); // "<b>Hi</b>"
escapeHtml('Tom & Jerry'); // "Tom & Jerry"
Notes
- This helper returns an empty string when
textis 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
- Start with a filename or URL like
image.webp. - Call
getFileExtension(path). - 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
- Extract an extension like
pdformp4. - Call
getFileIconByExt(extension). - 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
mimeTypeparameter 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
- Get a MIME type like
image/pngorapplication/pdf. - Call
getFileIcon(fileType). - 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
- Get the file extension (example:
pdf). - Decide if it should be treated as an image (
trueorfalse). - 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
isImageis true, it always returnsbi-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
- Start with a date string (example:
2025-12-22or ISO datetime). - Call
getISODate(dateString). - Use the returned
YYYY-MM-DDstring in your UI.
Signature
getISODate(dateString)
Example
getISODate('2025-12-22T14:30:00Z'); // "2025-12-22"
Notes
- This uses
new Date(dateString)andtoISOString(). - If
dateStringis 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
- Get a file size number in bytes.
- Call
getFormatSize(bytes). - 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
- Wrap your field in
.input-protector-wrapper. - Add an edit button that calls
toggleInputProtector(this). - Put your read-only view inside
.input-protector-display. - Put the real input inside
.input-protector-edit-modeand start it as hidden withd-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-displayblock. - Shows
.input-protector-edit-modeby removingd-noneand addingd-flex.
Notes
- This is only a UI helper. The form still submits normally.
- The default styles for
.input-protector-wrapperand.small-icon-btnare inpublic/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
- Start with a file path or URL.
- Call
isImageFile(path). - 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
- Add a button or link on your page.
- Call
windowLogin()on click. - 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 fromSITE_DATA.urlLocale.{url}comes fromSITE_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
- Add a logout button or menu item.
- Call
windowLogout()on click. - 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 fromSITE_DATA.urlLocale.{url}comes fromSITE_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
- Create a password input with an
id(example:password). - Create an icon element with id
password_icon(same id +_icon). - Call
togglePasswordVisibility('password')when the user clicks your toggle button.
Signature
togglePasswordVisibility(inputId)
What It Does
- Switches the input between
type="password"andtype="text". - Swaps icon classes on
{inputId}_iconbetweenbi-eyeandbi-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-eyeandbi-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
- Call
toast('Saved')after an action. - Optionally set the
typeto change the toast class. - 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 notdefault, the element gets classtoast-{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.jscallstrans('toast_' + dataToastText)and passes the result totoast().
Notes
- This helper creates one
.toast-containerand one active.toast-messageelement. - 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
- Call
await trans('your_key')inside anasyncfunction. - Optionally pass
addonto select a specific dictionary. - 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 useawait(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
- Choose a preference type name (example:
media,files). - Read the saved value using
getViewPreference(type). - 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
localStorageis unavailable (or throws), the getter returnsgrid. - 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
- Folder Structure
- manifest.php
- Language file
- Installer + migrations
- Console permissions
- Console config (tabs)
- Console view
- Endpoint (read)
- Controller (action)
- Window (form)
- Content layout (public)
- Advanced add-ons (optional)
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.phpand optionaldata.phpfor quick stats. - Toolbar icon: enable in manifest and add a toolbar button in the addon console.
- Menu listing: create
support/helpers.phpwithtasks_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
Need More Help?
For complete developer documentation and advanced guides, visit the documentation center.