The driver contract
The definitive method-by-method reference for a panel driver: every signature, argument, and exact return shape Core reads, plus the objects and helper trait you build on.
On this page
- The interface, the base class, and the trait
- capabilities()
- create()
- suspend()
- unSuspend(), terminate(), changePackage(), changePassword()
- accountSummary()
- sync()
- loginServer() and loginAccount()
- getIp()
- getPackage()
- getQuickActions()
- convertToReseller()
- The objects Core passes you
- $server
- $hosting
- The helper trait
- formatBytesAsFriendly()
- Rules to remember
A panel driver is one PHP class whose methods Core calls, and whose return values Core reads generically to render every screen. There are no views to write. The entire relationship between your panel and Core is this method contract, and Core reads the return shapes exactly, so this page documents each one precisely. Keep to these shapes and your panel lights up correctly across the admin service actions, the client hosting page, and the product package selector.
If you are new to panels, read the overview and follow the quickstart first. This is the reference you return to.
The interface, the base class, and the trait#
Three types define everything you work with.
App\HostingModule\Server\HostingManagerInterface is the contract. Your driver must be an instance of it. It declares every method Core may call.
App\HostingModule\Server\AbstractPanel is the base class you should extend. It implements the interface with safe "not supported" defaults for every method, pulls in the helper trait, and returns an empty capabilities() so nothing is shown until you opt in. You override only the methods your panel actually implements. This is the recommended path.
<?php
namespace Salieno\Panel\Acme;
use App\HostingModule\Server\AbstractPanel;
use App\HostingModule\Server\PanelCapability;
class Acme extends AbstractPanel
{
public function capabilities(): array
{
return [
PanelCapability::CREATE, PanelCapability::SUSPEND,
PanelCapability::UNSUSPEND, PanelCapability::TERMINATE,
];
}
public function create($hosting): array { /* ... */ }
// override only what you implement; everything else stays "not supported"
}App\HostingModule\Server\HostingModuleTrait is the helper trait, inherited automatically through AbstractPanel. It gives you the pre-configured HTTP client, URL builder, error sanitiser, input validator, and admin-notification helper described at the end of this page. Issue your API calls through these so behaviour stays consistent across panels.
You can implement HostingManagerInterface directly instead of extending AbstractPanel (the bundled cPanel driver does, using the trait for helpers), but then you own a correct implementation of every method. Extending the base is simpler and safer.
capabilities()#
public function capabilities(): arrayReturns a subset of PanelCapability::ALL. This is the one method you must always override when extending AbstractPanel, because the base declares nothing. Every UI surface asks capabilities() before it shows a control, so a capability you omit is a control Core hides, never a button that fails when clicked. Declare only what your panel truly supports. The full capability-to-UI mapping is in Capabilities.
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,
];
}create()#
public function create($hosting): arrayProvisions an account on the server. Called when a paid order needs a panel account and from the admin "Create" action. Returns:
[
'success' => bool,
'message' => string,
'data' => [
'username' => string,
'password' => string,
'ip' => string,
'nameserver1' => string, // nameserver1..nameserver4
'nameserver2' => string,
'nameserver3' => string,
'nameserver4' => string,
],
]Two rules matter here.
Never set the service status. Core decides the status from your success flag: true makes the service ACTIVE, false leaves it PENDING. Do not touch the service status yourself.
Echo the real password, or omit the key. The value you return in data.password is stored on the service and emailed to the customer, so it must be the password the account was actually created with. If you leave a data key out entirely, Core keeps its own pre-generated value for that field. This lets you accept Core's generated username/password and just confirm them back, or substitute values the panel assigned.
public function create($hosting): array
{
if (! $this->validateHostingInput((string) $hosting->domain, (string) $hosting->username)) {
return ['success' => false, 'message' => __('Invalid domain or username format.')];
}
// ... call the panel API to create the account ...
return [
'success' => true,
'message' => __('Account created.'),
'data' => [
'username' => $hosting->username,
'password' => $hosting->password, // the real one, so it can be emailed
'ip' => $ip,
'nameserver1' => $server->ns1 ?? null,
'nameserver2' => $server->ns2 ?? null,
],
];
}suspend()#
public function suspend($data): arrayThis is the one method whose argument is an array wrapper rather than the bare $hosting model. $data is:
$data = [
'hosting' => Hosting, // the service model
'request' => object { suspend_reason, suspend_email }, // why, and whether to notify
];Returns ['success' => bool, 'message' => string]. Unwrap it before you do anything else:
public function suspend($data): array
{
$hosting = $data['hosting'];
$reason = $data['request']->suspend_reason;
// ... call the panel API to suspend ...
$hosting->suspend_reason = $reason;
$hosting->suspend_date = now();
$hosting->save();
return ['success' => true, 'message' => __('Account suspended.')];
}unSuspend(), terminate(), changePackage(), changePassword()#
public function unSuspend($hosting): array
public function terminate($hosting): array
public function changePackage($hosting): array
public function changePassword($hosting): arrayEach takes the bare $hosting model and returns ['success' => bool, 'message' => string].
Note the exact camelCase `unSuspend` (capital S). A method named unsuspend will not satisfy the interface.
changePackage reads the target plan from $hosting->product->package_name — that field already holds the plan the customer is moving to, so you apply it, you do not compute it. changePassword reads the new password from $hosting->password (already decrypted).
Set the lifecycle fields Core expects as you succeed: clear suspend_reason/suspend_date on unsuspend, set termination_date on terminate, update package_name on a package change, then $hosting->save().
public function terminate($hosting): array
{
// ... call the panel API to delete the account ...
$hosting->termination_date = now();
$hosting->save();
return ['success' => true, 'message' => __('Account terminated.')];
}accountSummary()#
public function accountSummary($hosting): ?arrayReturns the usage snapshot that drives the client disk/bandwidth meters and the admin "Account Summary" card. Returns either null or:
[
'success' => true,
'raw_data' => mixed, // the panel's own response, kept for sync()
'processed_data' => [
'disk_used' => string, // pre-formatted, e.g. "2.4 GB"
'disk_limit' => string, // pre-formatted, or "Unlimited"
'disk_usage_percent' => number, // e.g. 61.5
'bandwidth_used' => string,
'bandwidth_limit' => string,
'bandwidth_usage_percent' => number,
// any extra keys you like; Core reads the ones above
],
]The sizes in processed_data are pre-formatted strings — format them with formatBytesAsFriendly() (below). The percents are plain numbers.
The falsey-when-absent rule. Core also uses accountSummary() to detect whether an account exists on the panel. When there is no account — no username to look up, no matching subscription, a lookup that comes back empty — you must return null (or another falsey value), not a zero-filled summary. Returning a truthy array tells Core the account exists. The AbstractPanel default already returns null, which is why omitting this method cleanly yields the client area's empty state.
public function accountSummary($hosting): ?array
{
$data = /* look the account up on the panel */;
if (! $data) {
return null; // no account -> Core treats the service as having none
}
return [
'success' => true,
'raw_data' => $data,
'processed_data' => $this->processStats($data),
];
}sync()#
public function sync($hosting): arrayPulls current data back from the server on the admin/client "Sync Info" action. Returns:
['success' => bool, 'message' => string, 'data' => processed_data]The data is the same processed_data shape as accountSummary(). In practice sync() calls accountSummary(), writes any fields worth persisting back onto $hosting (IP, plan, nameservers) with $hosting->save(), and returns the processed usage so the UI can refresh its meters.
public function sync($hosting): array
{
$summary = $this->accountSummary($hosting);
if (! $summary || ! isset($summary['raw_data'])) {
return ['success' => false, 'message' => __('Failed to fetch account data.')];
}
// persist anything useful from $summary['raw_data'] ...
$hosting->save();
return ['success' => true, 'message' => __('Synchronized.'), 'data' => $summary['processed_data']];
}loginServer() and loginAccount()#
public function loginServer($server): array // server-level (admin)
public function loginAccount($hosting): array // account-level (client SSO)Both return ['success' => bool, 'url' => string, 'message' => string]. Core opens url in a popup for one-click sign-in. Mint a fresh single-use SSO URL from the panel; fall back to the plain login page if the panel cannot mint one.
loginServer() does double duty: it is also the reachability / health check. The Server form's "Test connection" probe calls it, so verify credentials with a lightweight API call before returning success, and return a success => false with an actionable message when the panel is unreachable or the credentials are wrong. See Testing your panel.
public function loginAccount($hosting): array
{
$url = $this->ssoUrl($hosting->server, (string) $hosting->username);
if ($url === null) {
return ['success' => false, 'message' => __('Could not obtain a login session.')];
}
return ['success' => true, 'url' => $url];
}getIp()#
public function getIp($server): ?stringReturns the server's provisioning IP as a string, or null. The AbstractPanel default returns $server->ip_address ?? null; override it to ask the panel for its shared/provisioning IP and fall back to $server->ip_address on any error.
getPackage()#
public function getPackage($source): arrayFeeds the plan dropdown in product create/edit. The argument is polymorphic: $source is either a ServerGroup or a single Server. Branch on instanceof and, for a group, iterate its servers yourself. Returns:
[
'success' => bool,
'data' => [ serverId => [ 'Plan A', 'Plan B', ... ], ... ], // keyed by server id
'message' => string,
]The values are plain plan-name strings. Don't let one unreachable server fail the whole group — collect per-server errors and still return the plans you could list.
public function getPackage($source): array
{
if ($source instanceof \App\Models\ServerGroup) {
$servers = $source->servers()->where('status', true)->get();
} elseif ($source instanceof \App\Models\Server) {
$servers = collect([$source]);
} else {
return ['success' => false, 'data' => [], 'message' => __('Invalid source.')];
}
$packages = [];
foreach ($servers as $server) {
$packages[$server->id] = /* ['Plan A', 'Plan B', ...] from the panel */;
}
return ['success' => true, 'data' => $packages];
}getQuickActions()#
public function getQuickActions($hosting): arrayReturns the list behind the client "Quick Shortcuts" card — deep links into panel features. Each entry is:
['key' => string, 'label' => string, 'icon' => 'ph-*', 'url' => string]Return [] for none, which hides the card. Icons are Phosphor classes (the ph- prefix), for example ph-folder-open, ph-envelope, ph-database.
public function getQuickActions($hosting): array
{
return [
['key' => 'filemanager', 'label' => __('File Manager'),
'icon' => 'ph-folder-open', 'url' => $fileManagerSsoUrl],
['key' => 'email', 'label' => __('Email Accounts'),
'icon' => 'ph-envelope', 'url' => $emailSsoUrl],
];
}convertToReseller()#
public function convertToReseller($hosting): arrayBacks the admin "To Reseller" action and returns ['success' => bool, 'message' => string]. Only implement it — and only declare PanelCapability::RESELLER — when the panel really supports promoting an account to a reseller. Where the panel's model doesn't fit (Plesk treats resellers as a distinct account type you cannot promote into), leave the AbstractPanel default and omit the capability, and Core hides the control.
The objects Core passes you#
$server#
The server the operator configured, with the connection fields from the Server form (see Connection fields):
| Field | Meaning |
|---|---|
$server->hostname | protocol + host + port, as the admin entered it |
$server->username | API/admin username |
$server->password | admin/API password |
$server->api_token | API token, when token auth is used |
$server->verify_ssl | per-server TLS-verify toggle |
$server->ip_address | operator-set provisioning IP |
$server->ns1 .. $server->ns4 | default nameservers |
$server->id | the server's id |
$hosting#
The service being acted on:
| Field | Meaning |
|---|---|
$hosting->domain | the account's domain |
$hosting->username | account username |
$hosting->password | account password, decrypted |
$hosting->user->email | the customer's email |
$hosting->product->package_name | the target plan name |
$hosting->product->product_type | product type (2 = reseller product) |
$hosting->server | the parent $server |
$hosting->ip, $hosting->ns1 .. $hosting->ns4 | stored account IP / nameservers |
$hosting->save() | persist changes you make |
$hosting->suspend_reason, $hosting->suspend_date, $hosting->termination_date | lifecycle fields you set |
The helper trait#
Use these (inherited through AbstractPanel) so every panel behaves consistently. They are the reason your driver never hand-rolls an HTTP client or leaks raw errors.
panelHttp($server, array $headers = [], int $timeout = 30) — a pre-configured Laravel HTTP client that applies the per-server verify_ssl toggle, sane timeouts, a stable identifiable User-Agent, and Accept: application/json. Issue all API calls through it.
panelBaseUrl($server, int $defaultPort, bool $forceHttps = true) — builds the API base URL from $server->hostname, normalising the scheme and ensuring the panel port is present. Panel APIs on their SSL ports are HTTPS-only, so the default forces HTTPS.
sanitizeErrorMessage(?string $message) — masks IPs, paths, and tokens, and translates low-level cURL failures (SSL, timeout, DNS, connection refused) into actionable operator guidance. Wrap any raw error text with this before returning it in a message.
validateHostingInput(string $domain, string $username): bool — strict RFC-style domain and Linux/panel username validation. Call it at the top of create().
secureAdminNotification($hosting, $message, $url = null) — raises an admin notification on a failure, with the message masked. Use it when an operation fails so the operator sees the real cause.
decodeJsonResponse($response) — tolerant JSON decode that returns null on a non-JSON body (for example an HTML login page from a redirect), so you can report a clear error instead of misreading a connection problem.
$response = $this->panelHttp($server, ['Authorization' => $auth])
->get($this->panelBaseUrl($server, 2087) . '/json-api/gethostname', ['api.version' => 1]);
$data = $this->decodeJsonResponse($response);
if ($data === null) {
return ['success' => false, 'message' => $this->sanitizeErrorMessage($response->body())];
}formatBytesAsFriendly()#
A global helper for the usage meters: formatBytesAsFriendly($value, $from = 'B', $precision = 2) formats a size — given in the unit named by the second argument — into a friendly string. Panels typically hold sizes in megabytes, so pass 'M':
$summary['disk_used'] = formatBytesAsFriendly($diskUsedMb, 'M'); // "2.4 GB"
$summary['disk_limit'] = ($limitMb <= 0)
? __('Unlimited')
: formatBytesAsFriendly($limitMb, 'M');
$summary['disk_usage_percent'] = ($limitMb > 0)
? round(($diskUsedMb / $limitMb) * 100, 2)
: 0;Rules to remember#
- Return the exact shapes above. Core reads them generically; a missing or renamed key silently drops the value.
- In
create(), never set the service status, and echo the real password (or omit the key to keep Core's generated one). suspend($data)is the array-wrapper method; every other lifecycle method takes the bare$hosting.- Spell it
unSuspendwith a capital S. - Make
accountSummary()falsey when no account exists — that is how Core detects account presence. - Declare capabilities honestly; a capability you don't declare is a control Core hides, so a partial panel is first-class, not broken.
- Guard subscription-scoped operations so an ambiguous target can never act on every account, and mask errors with
sanitizeErrorMessage().
For the two complete drivers this contract is drawn from, read the bundled cPanel/WHM driver (implements every capability) and the Plesk driver (a deliberate subset). Next, wire the capabilities to the UI in Capabilities, or see how the runtime trust model loads your class in Security and the trust model.