Capabilities and the UI
How a panel's declared capabilities drive every Core screen — the full PanelCapability vocabulary, the control each one lights up, and why an honest subset makes a partial panel first-class rather than broken.
On this page
A panel extension never renders a screen of its own. Core owns every surface — the server-group picker, the admin service actions, the client hosting page, the product package selector — and decides what to draw from one thing your driver declares: its capabilities. Declare a capability and Core shows the control. Omit it and Core hides it. There is no third state where a button appears and then fails when clicked.
This article covers the PanelCapability vocabulary, exactly what each capability lights up, and the principle that makes a partial panel first-class: Core asks whether you support an action before it ever shows the control.
The capability vocabulary#
Capabilities are string constants on App\HostingModule\Server\PanelCapability. There are thirteen, grouped by what they govern:
namespace App\HostingModule\Server;
final class PanelCapability
{
// Provision / lifecycle
public const CREATE = 'create';
public const SUSPEND = 'suspend';
public const UNSUSPEND = 'unsuspend';
public const TERMINATE = 'terminate';
// Plan / password management
public const CHANGE_PACKAGE = 'change_package';
public const CHANGE_PASSWORD = 'change_password';
// Introspection
public const ACCOUNT_SUMMARY = 'account_summary'; // disk/bandwidth meters
public const PACKAGES = 'packages'; // listable plans for the selector
public const SYNC = 'sync'; // pull ip/usage/plan back
// Access / SSO
public const LOGIN_ACCOUNT = 'login_account'; // client SSO into the panel
public const LOGIN_SERVER = 'login_server'; // admin login + reachability test
public const QUICK_ACTIONS = 'quick_actions'; // per-feature client shortcuts
// Reseller
public const RESELLER = 'reseller'; // convert an account to a reseller
public const ALL = [ /* all thirteen, in the order above */ ];
}PanelCapability::ALL is the full set — the sensible declaration for a full-featured panel like cPanel/WHM. Your capabilities() method returns some subset of it.
What each capability lights up#
Each capability maps to one or more concrete controls. Declare only the capabilities behind which you have a working method, because Core will surface every control in this table the moment you list its capability.
| Capability | Constant | What it lights up in Core |
|---|---|---|
create | CREATE | Automatic provisioning on a paid order, plus the admin Create action |
suspend | SUSPEND | Admin Suspend, status-driven lifecycle, and the cron suspend pass |
unsuspend | UNSUSPEND | Admin Unsuspend and lifecycle-driven reactivation |
terminate | TERMINATE | Admin Terminate and the cron termination pass |
change_package | CHANGE_PACKAGE | Package change on upgrade/downgrade, plus admin Change Package |
change_password | CHANGE_PASSWORD | Admin Change Password and the client Change Password control |
account_summary | ACCOUNT_SUMMARY | Client usage meters (disk/bandwidth) and the admin Account Summary card |
packages | PACKAGES | The plan dropdown in product create/edit |
sync | SYNC | Admin and client Sync Info |
login_account | LOGIN_ACCOUNT | Client SSO Login to control panel and the admin login |
login_server | LOGIN_SERVER | Server-level login and the Server form's Test connection probe (your reachability check) |
quick_actions | QUICK_ACTIONS | The client Quick Shortcuts card |
reseller | RESELLER | Admin To Reseller |
A couple of these carry more weight than a single button. login_server doubles as the health check: the Test connection probe on the Server form calls your loginServer($server), so declaring it is what lets an operator verify a server is reachable before saving it. And account_summary is how Core detects whether an account exists at all — its return value must be falsey when there is no account, which the method contract covers in detail.
Core asks before it shows#
Every UI surface routes its decision through one method on the panel manager, HostingManager::supports():
public function supports(string $capability): bool
{
if (! $this->instance) {
return false;
}
try {
return in_array($capability, $this->instance->capabilities(), true);
} catch (\Throwable) {
return false;
}
}This is the whole mechanism. A screen calls supports('reseller') before it draws the To Reseller button; the client hosting page calls supports('quick_actions') before it draws the shortcuts card. If the capability is not in your declared list, the control is never rendered.
Two properties of supports() are worth internalising. First, an unresolved panel — the EmptyServer placeholder Core falls back to when no genuine, entitled driver could be loaded — supports nothing, so a service on a panel that failed to resolve degrades to a read-only view rather than throwing. Second, the call is wrapped in a try/catch that returns false on any error, so a driver whose capabilities() somehow throws is treated as supporting nothing, not as a crash on the page.
The payoff is the design rule that runs through the whole panel system: an undeclared action is hidden, never shown-and-failing. Your customers and operators only ever see controls that lead to a real API call.
Declaring capabilities in your driver#
There are two ways a driver ends up with a capability set, and they have opposite defaults. Pick deliberately.
Extend `AbstractPanel` (recommended). Its capabilities() returns an empty array by default, so nothing is declared until you opt in. You override the method to list exactly what you implement:
use App\HostingModule\Server\AbstractPanel;
use App\HostingModule\Server\PanelCapability;
class MyPanel extends AbstractPanel
{
public function capabilities(): array
{
return [
PanelCapability::CREATE,
PanelCapability::SUSPEND,
PanelCapability::UNSUSPEND,
PanelCapability::TERMINATE,
PanelCapability::LOGIN_SERVER,
];
}
// ...override only the methods for the capabilities above
}This is the safe path for any panel that does not do everything. Because the default hides all controls, a control can only appear once you have both declared its capability and written its method. You cannot accidentally leave a button pointing at an unimplemented stub.
Implement `HostingManagerInterface` directly. If you pull in HostingModuleTrait yourself instead of extending AbstractPanel, the trait supplies a default capabilities() that returns PanelCapability::ALL. This is the right choice only for a panel that genuinely implements every capability — cPanel does exactly this and does not override the method at all:
class Cpanel implements HostingManagerInterface
{
use HostingModuleTrait; // capabilities() defaults to PanelCapability::ALL
// every contract method is implemented; no capabilities() override needed
}The difference is the default. AbstractPanel starts you at nothing and makes you add; the trait starts you at everything and makes you subtract. A full panel can use either; a partial panel should extend AbstractPanel so its default is honest.
One safety net applies to both paths: Core sanitizes whatever you return by intersecting it with the recognised set (PanelCapability::sanitize()), so a typo like 'change_passwrod' is silently dropped rather than lighting up a control. A misspelled capability is a missing capability, never an error.
Honest capabilities: Plesk versus cPanel#
The two worked examples in the repo demonstrate both ends of the spectrum on purpose.
cPanel/WHM declares all thirteen. The WHM JSON API supports account creation, the full suspend/terminate lifecycle, package and password changes, usage stats, package listing, sync, both SSO paths, per-feature deep-links, and native reseller conversion. So Cpanel earns PanelCapability::ALL — it relies on the trait default and Core shows every control.
Plesk declares a subset — and that is the correct, first-class answer. The Plesk driver extends AbstractPanel and lists eleven of the thirteen:
class Plesk extends AbstractPanel
{
public function capabilities(): array
{
return [
PanelCapability::CREATE, PanelCapability::SUSPEND, PanelCapability::UNSUSPEND,
PanelCapability::TERMINATE, PanelCapability::CHANGE_PACKAGE, PanelCapability::CHANGE_PASSWORD,
PanelCapability::ACCOUNT_SUMMARY, PanelCapability::PACKAGES, PanelCapability::SYNC,
PanelCapability::LOGIN_ACCOUNT, PanelCapability::LOGIN_SERVER,
// NOT reseller / quick_actions — Plesk's model differs.
];
}
}It omits reseller and quick_actions, and the omissions are principled, not lazy. Plesk has no equivalent of WHM's "convert this account to a reseller" operation — a Plesk subscription is not something you flip into a reseller the way WHM converts an account — so faking a reseller capability would mean showing To Reseller on a button that could only ever fail. Likewise Plesk does not expose the per-feature SSO deep-links that the client Quick Shortcuts card is built from, so declaring quick_actions would surface an empty or broken card.
By leaving both out, the Plesk driver tells Core the truth. Core hides To Reseller and the Quick Shortcuts card for every Plesk service automatically — no core edits, nothing shown-and-failing. Everything Plesk does declare is backed by a real Plesk API call.
A partial panel is first-class#
The lesson is that the capability system rewards honesty. There is no penalty for a smaller declaration: a panel that supports create, suspend, terminate and server login — and nothing else — is a completely valid, shippable extension. Core simply renders a leaner service page for it, with only the controls it can honour.
The failure mode to avoid is the opposite: declaring a capability whose semantics do not fit your panel, in the hope of looking complete. That produces exactly the shown-and-failing button the whole system exists to prevent. Declare what you actually implement, back each declared capability with the method behind it, and let Core adapt every surface around your honest answer.
Next, wire those declarations to real methods — the method contract specifies the exact return shape for each one, and testing your panel shows how to verify that the controls you lit up behave.