Adding an Admin Page
Give a module its own page in the admin panel with two files. An area class returns the HTML; one line in router.php registers it.
Overview
A module that manages its own records or runs a bulk job needs a real page. That page does not belong in the core: you declare a class and register it, and the route, menu entry, privilege check, breadcrumb and theme shell come for free. New to modules? See Your First Module.
The dispatcher is coremio/controllers/admin/module-page.php; slug routes resolve to it and the result goes to the addon theme wrapper.
Structure
register(), route wiring, menu hook, instance helpers.
page_* and op_*, invisible in the URL.
Router::loadModuleRouters() reads the router file before any route is matched.
Walkthrough
Declare the Area Class
- Create
AdminArea.phpin your module directory, namespacedWISECP\Modules\{Type}\{Name}. - Extend
\ModuleAdminAreaand return a manifest from the staticmanifest()method. - Override
available()when the page only makes sense under a condition; it guards the menu entry too.
Register It
- Create
router.phpnext to it: include the class file, then call\ModuleAdminArea::register(AdminArea::class). - Nothing else belongs there. Registration adds three routes, stores the manifest and hooks the menu entry.
Write a Page Method
page_home()answers the area root; every method takes the trailing URL segments as an array.- Build the HTML with heredoc; read your strings from
$this->lang. - Point links at yourself with
$this->link(), never a hand-written path.
Write an Operation
- Name the method
op_{name}; only that prefix is callable. - Post to the area URL with an
operationparameter; a thrown exception becomes the standard JSON error. - Start with
$operation->demo(), read input throughFilter::init(), finish with$operation->output().
Store Area Settings
- Use
settings(),setting()andsave_settings(), not a file of your own. save_settings()merges into the current values, so a partial save keeps the rest, then firesaction:module.area_settings_saved.
Reference
Base Class API
class ModuleAdminArea
{
public string $module_type = ''; // resolved from the namespace
public string $module_name = ''; // resolved from the namespace
public array $lang = []; // {module}/lang/{selected}.php, falling back to en.php
public function __construct();
// You override this one. Anything else with a default is optional.
public static function manifest(): array;
public static function register(string $areaClass): void;
public static function get(string $type, string $name): ?array;
public function available(): bool; // default true
public function slug(): string;
public function link(array $params = []): string;
public function module_dir(): string; // filesystem path, trailing separator
public function module_url(): string; // public URL of the module directory
public function settings(): array;
public function setting(string $key, mixed $default = null): mixed;
public function save_settings(array $values): void;
protected function license_state_badge(string $slug): string;
}
The accepted types are Servers, Payment, Registrars, Product, Addons, SMS, Mail, Authentication, Pipe, Imports and Fraud. For anything else register() does nothing, silently.
What the Manifest Accepts
menu.name is absent.
Filter::route() keeps a-zA-Z0-9, hyphen, underscore and dot.
['path' => ['PRODUCTS', 'GROUP_HOSTING_SERVER'], 'name' => 'Hetzner Cloud']. The path walks down the tree; omit it and no entry is created.
register() from the namespace and the argument; do not set them.
URL Scheme and Method Resolution
// Three routes, most specific first. $target is module-page/{Type}/{Name}.
$router->add($slug . '-2', $slug . '/(?)/(?)', $target . '/(1)/(2)');
$router->add($slug . '-1', $slug . '/(?)', $target . '/(1)');
$router->add($slug, $slug, $target);
// Which is why link() derives the route key from the parameter count:
// $this->link() -> /{admin}/{slug}
// $this->link(['configuration']) -> /{admin}/{slug}/configuration
// $this->link(['logs', 'archive']) -> /{admin}/{slug}/logs/archive
| Request | Method called | Argument |
|---|---|---|
GET /{admin}/{slug} | page_home() | empty array |
GET /{admin}/{slug}/configuration | page_configuration() | ['configuration'] |
GET /{admin}/{slug}/logs/archive | page_logs_archive(), else page_logs() | ['logs', 'archive'] |
POST with operation=sync_prices | op_sync_prices(Operation $operation) | the Operation |
| no matching method | 404 page | nothing appears |
Hyphens, dots and commas in a segment become underscores: /{slug}/price-list resolves to page_price_list(). Operation names too.
What a Page Method May Return
A string becomes the page body; an array passes the keys below to the wrapper.
| Key | Type | Effect |
|---|---|---|
content | string | The page body. Empty shows a No module area content is available. alert. |
page_title | string | Overrides the manifest title. |
page_title_buttons | array | A list of button descriptors; plain HTML shows nothing. |
page_title_after | string | Free HTML after the title and buttons. |
page_title_logo | string | An image URL, or raw HTML when it contains <. |
content_layout | string | panel by default; plain drops the panel. |
content_data_class | string | Extra class on the content wrapper (plain layout). |
breadcrumbs | array | Appended to the Dashboard + area title trail. |
plugins | array | Merged onto the wrapper defaults. |
page_styles, page_scripts, modals | string | Head, footer and modal area. |
The Signed-in Administrator
public static function LoginData($type = 'member', $isRemembered = false, $recheck = false);
$adminId = (int) (\UserManager::LoginData('admin')['id'] ?? 0); // 0 outside the panel (CLI, cron)
// The operator picker for an assignment field.
$staff = \Admin::list(); // [id => ['id' => 1, 'full_name' => 'Jane Doe'], ...]
Example
A two-file area for an imaginary Acme module.
<?php
namespace WISECP\Modules\Servers\Acme;
use AdminFormBuilder;
use Exception;
use Filter;
use Operation;
use User;
use UserManager;
use WDB;
class AdminArea extends \ModuleAdminArea
{
public static function manifest(): array
{
return [
'title' => 'Acme',
// 'slug' => 'acme', // default: strtolower(module name)
'privileges' => ['PRODUCTS_OPERATION'],
'menu' => ['path' => ['PRODUCTS', 'GROUP_HOSTING_SERVER'], 'name' => 'Acme'],
];
}
/** Hides the menu entry and 404s the page while no Acme server exists. */
public function available(): bool
{
static $available = null;
if ($available === null)
$available = (bool) WDB::select('id')->from('servers')
->where('type', '=', 'Acme', '&&')
->where('status', '=', 'active')
->build();
return $available;
}
public function page_home(array $params): array
{
$L = $this->lang;
$conf = $this->settings();
$form = new AdminFormBuilder('acmeAreaForm', $this->link(), ['disableStickySubmit' => true]);
$form->addHidden('operation', 'save_settings');
$form->addAmount('profit_rate', $L['profit-rate'] ?? '', (string) ($conf['profit_rate'] ?? 25));
$form->addSwitch('auto_sync', $L['auto-sync'] ?? '', $L['auto-sync-desc'] ?? '', '1', (int) ($conf['auto_sync'] ?? 0) === 1);
return [
'content' => $form->render(),
'page_title' => $L['area-title'] ?? 'Acme',
'page_title_after' => $this->license_state_badge('acme'),
];
}
public function op_save_settings(Operation $operation): bool
{
$operation->demo();
$values = [
'profit_rate' => (float) Filter::init('POST/profit_rate', 'amount'),
'auto_sync' => (int) Filter::init('POST/auto_sync', 'rnumbers') === 1,
];
if ($values['profit_rate'] < 0)
throw new Exception($this->lang['err-negative-rate'] ?? 'The profit rate cannot be negative.');
$this->save_settings($values);
$adminId = (int) (UserManager::LoginData('admin')['id'] ?? 0);
User::addAction($adminId, 'update', 'acme-settings-updated', ['changes' => $values]);
return $operation->output([
'status' => 'successful',
'redirect' => 'reload',
]);
}
}
<?php
namespace WISECP\Modules\Servers\Acme;
include_once __DIR__ . DS . 'AdminArea.php';
\ModuleAdminArea::register(AdminArea::class);
The dispatcher decides whether your method is reached at all:
$area_info = ModuleAdminArea::get($type, $name);
if (!$area_info) return $this->page_404();
if (($area_info['privileges'] ?? []) && !Admin::isPrivilege($area_info['privileges'])) return 'Access Denied';
$area = new $area_info['class']();
if (!$area->available()) return $this->page_404();
if ($operation = Filter::init('REQUEST/operation')) return $this->area_operation($area, $operation);
$page = Filter::route($this->params[2] ?? '') ?: 'home';
$subpage = Filter::route($this->params[3] ?? '');
$filter_method = fn ($param) => str_replace(['-', '.', ','], '_', $param);
$method = $filter_method('page_' . $page); // page_logs
$method2 = $filter_method($method . '_' . $subpage); // page_logs_archive
$page_params = array_slice($this->params, 2);
if ($subpage && method_exists($area, $method2)) $result = $area->$method2($page_params);
elseif (method_exists($area, $method)) $result = $area->$method($page_params);
else return $this->page_404();
if (!is_array($result)) $result = ['content' => (string) $result];
Pitfalls
An undefined property or a forgotten import produces no output, and try/catch on Exception will not catch it because an Error is not an Exception. The commonest case is Admin::$data['id']: that static property does not exist, so read the operator with UserManager::LoginData('admin').
Real controller files win at dispatch, so an area registered as services is unreachable and overwrites that key for every link. Pick a slug no core route uses.
The menu hook walks down the tree and returns as soon as a step is absent. If the parent was trimmed by the administrator's privileges your entry is not added, while the page stays reachable by URL.
The area dispatcher checks the licence before it looks for your method and answers with a JSON error. Pages are affected too, at the view layer.
The row key in the configurations table is the module name as the directory spells it, so renaming the directory orphans the saved settings. Migrate by overriding settings().
Related Articles
Vielen Dank für Ihre Rückmeldung!
Unser Support-Team ist rund um die Uhr für Sie da, wenn Sie oben nicht fündig werden.