Skip to content

Writing a module

A module is a folder under modules/. Drop it in, add a pnlcs.json manifest, and PNLCS registers it on the next request — no edit to the core, nothing to re-register after an update, because your folder is not part of this repository.

1. Folder layout — exactly two levels below modules/:

modules/
└── Gateways/                 ← Gateways, Servers, Registrars or Ssl
    └── AcmePay/
        ├── AcmePayModule.php
        └── pnlcs.json

The namespace follows the path (PSR-4, Modules\ → modules/), so the class above is Modules\Gateways\AcmePay\AcmePayModule.

2. The manifest — pnlcs.json:

{
    "name": "acmepay",
    "type": "gateway",
    "class": "Modules\\Gateways\\AcmePay\\AcmePayModule",
    "version": "1.0.0",
    "display_name": "AcmePay",
    "description": "AcmePay card payments",
    "author": { "name": "Your Company" }
}
Field Required Meaning
name yes Unique key, stored on invoices, products and settings. Lower-case, no spaces.
type yes gateway, server, registrar or ssl
class yes Fully qualified class name of the module
version, display_name, description, author no Informational

Two rules are enforced when the manifest is read, so a broken module cannot break a working installation:

  • A built-in module always wins. A manifest named stripe (or any other built-in name) is ignored — it cannot replace a module that ships with PNLCS.
  • The class must be the type it claims. The class must implement the interface for its type (table below). A server class announced as a gateway is not registered at all, instead of failing at checkout.

A manifest that does not parse, names a class that does not exist, or leaves out name, type or class is skipped silently.

3. The interface — implement the one for your type (app/Contracts/):

type Interface Methods
gateway App\Contracts\GatewayModuleInterface capture, refund, getPaymentForm, processWebhook, getConfigFields, getModuleName, isTokenised
server App\Contracts\ServerModuleInterface create, suspend, unsuspend, terminate, changePassword, changePackage, usageUpdate, testConnection, getConfigFields, getModuleName
registrar App\Contracts\RegistrarModuleInterface register, transfer, renew, getNameservers, saveNameservers, getEPPCode, getLockStatus, toggleLock, checkAvailability, getConfigFields, getModuleName
ssl App\Contracts\SslModuleInterface purchaseCertificate, getCertificateStatus, renewCertificate, revokeCertificate, reissueCertificate, resendValidationEmail, changeValidationMethod, getApproverEmails, getWebServerTypes, getCertificateTypes, decodeCsr, generateCsr, testConnection, getConfigFields, getModuleName

A gateway that can store a card and charge it later (automatic payment) also implements App\Contracts\TokenizableGatewayInterface (beginVaulting, confirmVaulting, detachStoredMethod, chargeStoredMethod); the Stripe, iyzico and PayPal modules are complete examples.

Actions return an array with at least success (bool) and message (string) — for example ['success' => true, 'message' => 'Account created'].

4. Settings — for gateway, registrar and SSL modules, getConfigFields() describes the fields on the module's settings page; PNLCS draws the form and stores the values. A gateway is only offered at checkout once every field marked required has a value. (Server modules get their connection details — hostname, port, username, password or API key, access hash — from the Setup → Servers form instead.)

public function getConfigFields(): array
{
    return [
        ['name' => 'api_key',  'label' => 'API Key',   'type' => 'password', 'required' => true],
        ['name' => 'mode',     'label' => 'Mode',      'type' => 'select',   'options' => ['live' => 'Live', 'test' => 'Test']],
        ['name' => 'debug',    'label' => 'Debug log', 'type' => 'yesno',    'default' => '0'],
        ['name' => 'note',     'label' => 'Note',      'type' => 'textarea'],
    ];
}

Field types: text, password (never echoed back into the page), textarea, select (with options) and yesno. A gateway reads its saved values from App\Models\GatewaySettings (gateway = your name, setting = the field name); the Mollie and Tpay modules show the pattern in a few lines.

5. Start from a working module. The simplest complete examples are modules/Servers/Custom (a server module where every action succeeds) and modules/Gateways/BankTransfer (an offline gateway). Copy one, rename the folder, namespace and class, write the manifest, and open Setup → Modules — your module appears there marked Third-party.

Current limitation — gateway webhooks. Payment-confirmation webhooks are routed to the built-in gateways by name (/gateway/stripe/webhook, /gateway/paypal/webhook, …). A third-party gateway's processWebhook() has no public URL yet, so a gateway that confirms payments only through webhooks cannot be completed as a drop-in module today. Redirect-and-return gateways and server, registrar and SSL modules are not affected.

Addons (modules/Addons/<Name>/<Name>Module.php, implementing App\Contracts\AddonModuleInterface) need no manifest: any addon folder is listed on the Extensions page (/admin/config/addons/modules, in the settings sidebar) and on the Modules screen, where it is activated. The Staff Board and Project Management addons are working examples.

Tests. tests/Feature/ModuleDiscoveryTest.php shows how to exercise a module through the same discovery the application uses. Pull requests that add a module with tests are reviewed first.

Optional capabilities

A module can offer more than its interface requires:

Implement And PNLCS will
App\Contracts\TokenizableGatewayInterface (gateways) store cards at the gateway and charge them for renewals
App\Contracts\SyncsDomainData (registrars: syncDomain) read each domain's expiry, status and nameservers in the nightly domain sync
App\Contracts\HostsAccountDomains (servers: accountDomains, createAccountDomain) offer customers Set up on my hosting for their domains (guide)
a customFunctions() method (servers) returning method name => label run those methods on a service through the API (modulecustom)

Hooks in a module

A hooks.php file in the module's folder is loaded on every request (for addons: only while the addon is active). See Hooks.