Skip to content

Create your first panel

A hands-on walkthrough that builds a minimal but real panel extension end to end — the two-file package, the driver, its capabilities, a working create() and loginServer(), and how it reaches an install.

7 min readUpdated Aug 15, 2026
On this page

A panel extension is smaller than most people expect. There is no application to scaffold, no views to build, no routes to register. Two files — a driver class and a manifest — and Core does the rest. This walkthrough builds a minimal but real panel from scratch so you can see the whole shape before you fill in the detail.

We will stub a fictional "Acme Panel" that speaks a plain JSON API. The panel's own endpoints here (/api/accounts, /api/ping) are illustrative — swap them for your panel's real ones — but everything Core touches (the base class, the return shapes, the helpers, the capabilities, the manifest) is exact.

What you'll build#

A panel that can do two things: provision an account and log in to the server. That is enough to be genuinely useful and to see every moving part. Once it works, adding suspend, usage meters, SSO and the rest is just more methods of the same kind — the driver contract covers them all.

Lay out the package#

A panel is a folder with two files:

code
acme-panel/
    salieno.json      the manifest
    AcmePanel.php     the driver class

That is the entire package. No composer.json, no resources/, no assets. If you split logic into helper classes, put them under your namespace alongside the driver — but you never need to.

Extend AbstractPanel and declare capabilities#

Start from App\HostingModule\Server\AbstractPanel. It already implements every contract method with a safe "not supported" default and pulls in the driver-helper trait, so you override only what you actually build. Crucially, extending the base declares nothing — every control stays hidden until you opt in through capabilities().

php
<?php

namespace Acme\Panel;

use App\HostingModule\Server\AbstractPanel;
use App\HostingModule\Server\PanelCapability;

class AcmePanel extends AbstractPanel
{
    public function capabilities(): array
    {
        return [
            PanelCapability::CREATE,
            PanelCapability::LOGIN_SERVER,
        ];
    }
}

That is a valid, installable panel. It just does nothing yet, because create() and loginServer() still return the base's "not supported" default. capabilities() is the authoritative source at runtime — Core reads it to decide which controls to render. Declare CREATE and Core wires up provisioning on a paid order plus the admin "Create" action; declare LOGIN_SERVER and Core shows server login and uses your method as the "Test connection" probe on the Server form. Omit a capability and Core hides that control entirely — never shown-and-failing. The full map lives in Capabilities.

Implement create()#

create($hosting) provisions the account and returns whether it worked plus the real credentials. Keep to the shape exactly — Core reads it generically:

php
public function create($hosting): array
{
    try {
        $server = $hosting->server;

        if (! $this->validateHostingInput((string) $hosting->domain, (string) $hosting->username)) {
            return ['success' => false, 'message' => __('Invalid domain or username format.')];
        }

        $response = $this->panelHttp($server, ['Authorization' => 'Bearer ' . $server->api_token])
            ->post($this->baseUrl($server) . '/api/accounts', [
                'domain'   => $hosting->domain,
                'username' => $hosting->username,
                'password' => $hosting->password,
                'plan'     => $hosting->product->package_name,
                'ip'       => $server->ip_address,
            ]);

        $body = $this->decodeJsonResponse($response) ?? [];
        if (! $response->successful() || ! ($body['ok'] ?? false)) {
            $message = $this->sanitizeErrorMessage($body['error'] ?? __('The panel rejected the account.'));
            $this->secureAdminNotification($hosting, $message);
            return ['success' => false, 'message' => $message];
        }

        // Echo the real credentials so Core stores + emails them. Omit a key to keep
        // Core's pre-generated value. Do NOT set the service status — Core decides
        // ACTIVE (success) vs PENDING (failure) from the flag below.
        return [
            'success' => true,
            'message' => __('Account created.'),
            'data'    => [
                'username'    => $hosting->username,
                'password'    => $hosting->password,
                'ip'          => $server->ip_address,
                'nameserver1' => $server->ns1,
                'nameserver2' => $server->ns2,
            ],
        ];
    } catch (\Throwable $e) {
        return ['success' => false, 'message' => $this->sanitizeErrorMessage($e->getMessage())];
    }
}

/** Build the panel's API base URL, defaulting to its HTTPS port. */
protected function baseUrl($server): string
{
    return $this->panelBaseUrl($server, 8090, true);
}

Three habits to notice, because they apply to every method you write:

  • Always issue API calls through `panelHttp($server, $headers)`. It applies the per-server TLS-verify toggle, sane timeouts, a stable User-Agent and an Accept: application/json header — details that matter the moment a real server sits behind a WAF.
  • Build URLs with `panelBaseUrl($server, $defaultPort)`, which normalises the scheme and ensures the port. Never string-concatenate $server->hostname by hand.
  • Wrap any error text in `sanitizeErrorMessage()` before returning it. It masks IPs, paths and tokens, and translates raw cURL failures (SSL, timeout, DNS, refused) into guidance an operator can act on.

The password you put in data.password is stored on the service and emailed to the customer, so echo the real one. Omit a key and Core keeps the value it pre-generated. And never set the service status yourself — the success flag decides it.

Implement loginServer()#

loginServer($server) returns a URL Core opens in a popup, and doubles as the reachability check behind the Server form's "Test connection" button. So do a real credential probe first, then hand back the login URL:

php
public function loginServer($server): array
{
    try {
        // Reachability + credential check. Core also runs this as "Test connection".
        $response = $this->panelHttp($server, ['Authorization' => 'Bearer ' . $server->api_token])
            ->get($this->baseUrl($server) . '/api/ping');

        if (! $response->successful()) {
            return ['success' => false, 'message' => __('Could not authenticate against the panel.')];
        }

        return ['success' => true, 'url' => $this->baseUrl($server) . '/login'];
    } catch (\Throwable $e) {
        return ['success' => false, 'message' => $this->sanitizeErrorMessage($e->getMessage())];
    }
}

That is the whole driver: a class, two capabilities, two methods. The other objects you saw — $hosting->domain, $hosting->product->package_name, $server->api_token — are handed to you by Core; you never fetch them.

Write salieno.json#

The manifest tells the marketplace and Core how to load and present your panel, and supplies the Server form's connection fields.

json
{
    "schema": "salieno.panel/1",
    "kind": "panel",
    "slug": "acme-panel",
    "name": "Acme Panel",
    "version": "1.0.0",
    "namespace": "Acme\\Panel\\",
    "driver": "AcmePanel",
    "entry": "AcmePanel.php",
    "requires_core": ">=1.0.0 <2.0.0",
    "capabilities": ["create", "login_server"],
    "connection": {
        "label": "Acme Panel",
        "port": "8090",
        "auth": "token",
        "username_label": "Admin user",
        "password_label": "Admin password",
        "token_label": "API token",
        "token_help": "Create an API token in Acme Panel under Settings -> API.",
        "docs": "https://docs.acmepanel.example/api"
    },
    "credentials": {
        "username_max_length": 16
    }
}

A few fields carry weight. slug is both what a server group stores and your marketplace product slug — they must match. driver, namespace and entry must line up with the class above (entry is required and must end in .php). requires_core is the Core version window your panel supports, not your own version. The capabilities array here is informational — the authoritative list is your capabilities() method — so keep the two in step. Field-by-field detail is in The manifest, and the connection block has its own guide in Connection & credentials.

There are no views#

Worth stating plainly: you do not build a single screen. Core owns the group picker, the admin service actions, the client hosting page and the product package selector, and renders each one from what your driver returns and declares. Your job ends at correct return shapes. That is why a partial panel like this one is first-class rather than broken — Core simply shows the two controls you declared and hides the rest.

Ship it: submit, then install#

A panel folder on a server does nothing on its own — distribution is marketplace-only and signed. To get yours running:

  1. Submit the two-file package to marketplace.salieno.com and set its price at creation (free, or a one-time paid product). A reviewer approves it, and on approval the marketplace signs the artifact with its key.
  2. Install from the admin panel under Server Groups → Extensions. That library lists only panels the licence owns; installing runs a signed resolve → download → verify → register path, checking the download against the pinned marketplace key before any file is written.

From there the panel appears in the server-group picker, and every capability you declared lights up automatically — no Core edits, ever. The full path, plus how updates work, is in Publishing & updates, and the trust model that makes a copied folder inert is covered in Security & the trust model.

Before you submit, exercise the driver against a real panel — see Testing your panel. Then flesh it out method by method against the contract: suspend and terminate, usage meters, package listing, account SSO. Each one is the same small, self-contained shape you just wrote twice.

panelsquickstarttutorial
Was this article helpful?
Still stuck?Contact support
Create your first panel · Salieno Docs