Skip to content

Writing a module

Updated

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
provider no Fully qualified class of a Laravel service provider in the module's folder: its own routes, views, migrations and scheduled tasks (see below)
requires no {"pnlcs": ">=1.3 <2"}: the PNLCS versions the module works with. An update to a version outside it does not start until the operator updates the module, disables it, or chooses to go ahead
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 server module that sells something bought per period from someone else (a software license, a resold service) can also offer renew(Service $service): array. It is called after a renewal invoice for the service is paid, once the due date has moved, so the period is bought from the vendor only when the customer has paid. A refusal is logged, raised as ModuleActionFailed (action renew) and queued: the retry queue (pnlcs:module-queue) tries it again, so make renew() safe to repeat for the same paid period (key it by the service's due date, as the Panelica license module does).

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 (add 'secret' => true for a key or certificate that must not be shown again: the page then shows only that a value is set, and a blank save keeps it), select (with options) and yesno. A gateway field can carry a description, a sentence shown under it on the settings page. 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.

Gateway callbacks. A gateway without a route of its own receives its provider's payment confirmations at /gateway/<name>/callback (GET or POST, no CSRF; route('gateway.callback', '<name>')). The route answers only while the gateway is switched on with its required settings filled in.

  • processWebhook() gets the request fields plus _raw_payload (the raw body) and _headers (lower-case names). Verify the call yourself: signature, amount, currency.
  • Return ['success' => true, 'invoice_id' => …, 'transaction_id' => …, 'amount' => …] to apply a payment. Always return the provider's transaction id: it is what keeps a retried callback from paying twice, and a success without one is logged and not applied.
  • response (the body your provider expects, OK by default) and http_status set the reply. Return them on failures too, so the provider stops retrying.

Pages, webhooks and tables of a module. A module may need more than the callback: a checkout page of its own, a return URL, a webhook that answers differently, a table. Name a Laravel service provider in the manifest ("provider": "Modules\\Gateways\\AcmePay\\AcmePayServiceProvider") and PNLCS registers it while the module is registered, so the framework's own means work from the module's folder, and an update, which resets the core's files, leaves them in place:

// modules/Gateways/AcmePay/AcmePayServiceProvider.php
namespace Modules\Gateways\AcmePay;

use Illuminate\Support\ServiceProvider;

class AcmePayServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        $this->loadRoutesFrom(__DIR__.'/routes.php');
        $this->loadViewsFrom(__DIR__.'/views', 'acmepay');
        $this->loadMigrationsFrom(__DIR__.'/migrations');   // run by `php artisan migrate`, a step of every update
    }
}
  • A route file without middleware has none: a webhook there gets the raw body and headers and no CSRF check. Put pages for customers behind the client area's guards (['web', 'banned.ip', 'client.permission', 'auth', '2fa']).
  • Check the gateway is on in your own controllers (a module's routes exist while its folder does). Apply a payment with app(\App\Services\PaymentService::class)->applyPayment($invoice, '<name>', $transactionId, $amount): as with the callback, the provider's transaction id is what keeps a repeat from paying twice.
  • A route named gateway.<name>.… counts as the gateway's own: the generic /gateway/<name>/callback then stops answering for it. Name routes under your module's own prefix otherwise.
  • A module the core shadows (a built-in name) or whose class is not of its type brings no provider either. A provider that fails is logged (Module gateway <name>: service provider failed to load) and skipped.
  • After adding or removing a module on an installation that caches routes, run php artisan optimize.

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.

Admin menu entries. While an addon is active, the entries its sidebar() returns are listed in the Extensions menu of the admin top navigation. An entry is ['label' => …, 'url' => …], optionally with 'children' of the same shape; entries without a label or a url are left out, and an addon whose sidebar() throws is logged and skipped. The addon's own page (output()) lives at /admin/config/addons/modules/<name>.

Pages, views and tables from an addon. An addon may ship its own Laravel service provider, modules/Addons/<Name>/<Name>ServiceProvider.php (class Modules\Addons\<Name>\<Name>ServiceProvider). PNLCS registers it while the addon is active, so through it the addon can use the framework's own means: routes (pages for customers or visitors), views, migrations and scheduled tasks. Inactive addons, and addons without the file, change nothing. A provider that fails - its file does not load, or its register() or boot() throws - is logged with the addon's name (Addon <name>: service provider failed to load) and skipped; the panel and the other addons keep working. Activating or deactivating an addon clears cached routes (php artisan optimize), so its pages appear and disappear at once.

// modules/Addons/Reviews/ReviewsServiceProvider.php
namespace Modules\Addons\Reviews;

use Illuminate\Support\ServiceProvider;

class ReviewsServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        $this->loadRoutesFrom(__DIR__.'/routes.php');
        $this->loadViewsFrom(__DIR__.'/views', 'reviews');      // view('reviews::index')
        $this->loadMigrationsFrom(__DIR__.'/migrations');
    }
}
// modules/Addons/Reviews/routes.php
use Illuminate\Support\Facades\Route;

// A client-area page: the same guards as the client area's own pages.
Route::middleware(['web', 'banned.ip', 'client.permission', 'auth', '2fa'])
    ->prefix('client')->name('client.')
    ->group(function () {
        Route::get('reviews', [\Modules\Addons\Reviews\ReviewsController::class, 'index'])->name('reviews.index');
    });

// A public page.
Route::middleware('web')->get('reviews', [\Modules\Addons\Reviews\PublicController::class, 'index'])->name('reviews.public');

Keep route paths and names under your addon's own prefix so they cannot clash with PNLCS or other addons.

Upgrades. PNLCS records the version an addon was activated at. When the addon's files carry a newer getVersion(), its upgrade($fromVersion) runs once, with the recorded version, when php artisan pnlcs:addons-upgrade runs (a step of the update sequence, right after migrate --force; see Updating), or before the addon is switched back on if it was off. Return ['success' => true] and the new version is recorded; on failure (or an exception) the old version stays, the error is logged, the command exits non-zero and the upgrade is tried again on the next run. A failed upgrade also keeps an inactive addon off. Each addon's upgrade runs under a lock, so two runs at once never upgrade it twice. Until it has run, the Extensions screen shows the upgrade as pending.

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), and show them as buttons on the staff service page
clientFunctions() (servers), method name => label show those buttons to the customer on their service page; only these can be run from the client area
clientAreaPanel(Service) / adminAreaPanel(Service) (servers), returning a view draw the module's own section on the customer's / staff service page
servicePageHides() (servers), a list of server_details, change_password leave out the server's name and address on the customer's page, or the Change password button on the staff page
isProvisioned(Service): bool (servers) decide whether the staff page offers Create (otherwise: an active service with a username has nothing to create)
emailMergeFields(Service) (servers), name => value add {name} fields to the service's emails; the built-in fields win a clash
accountOnServer(Service): ?bool (servers) ask staff what happens to the account before they close a service record (mark it Terminated or Cancelled, or delete it) while the account is still on the server: true it is there, false it is gone, null the module cannot tell. Without it, an active or suspended service is taken to have its account (guide)
domainHostedOn(Server, string $domain): ?bool (servers) refuse an order, while the customer orders, for a domain that is already set up on the servers the order would go to; null (could not ask) lets the order through
packageChangeProblems(Service, Product): array (servers), returning problems and warnings (lists of sentences) refuse a move to that package on the customer's Upgrade / Downgrade page and through the API while problems is not empty, and show warnings there; a module that throws stops nobody
needsServerRecord(): bool (servers), returning false for a module that sells through an account kept elsewhere: it is not offered on Setup → Servers, and the product form asks listPackages(null) for its plans (an exception's message is shown as the reason when it cannot list them)
App\Contracts\ChecksAvailabilityInBulk (registrars: checkAvailabilityBulk) ask about the searched name and its suggested endings in one call; names the registry did not answer for fall back to WHOIS
App\Contracts\ManagesChildNameservers (registrars: getChildNameservers, saveChildNameserver, deleteChildNameserver) show the glue record (child nameserver) form on the domain page
App\Contracts\ManagesDomainContacts (registrars: getContact, saveContact) show the WHOIS contact form on the domain page
App\Contracts\ManagesWhoisPrivacy (registrars: setPrivacy) let the customer and staff switch WHOIS privacy on and off; without it the setting is shown but cannot be changed
testConnection() (registrars), returning success and message show a Test button for the registrar on Setup → Domain Registrars
syncPrices() (registrars), returning success and the counts updated, unchanged, skipped show Sync prices, which overwrites the prices of the extensions whose auto registrar is this one
importTlds() (registrars), returning success and the counts added, skipped show Add TLDs, which adds every extension the registrar offers to the price list and leaves existing ones alone
App\Contracts\RestorableRegistrar (registrars: restore) restore a domain out of redemption once the restore invoice is paid; without it staff get a to-do to do it by hand

customFunctions() and clientFunctions() are given the service, so a module can offer a button only where it applies; a module that declares no parameter is unaffected. A function takes the service and answers like any module action. A section or a list that throws is left out and logged, and the page still loads. See App\Services\Module\ModuleScreens; the Panelica license module (modules/Servers/PanelicaLicense, guide) uses all of them.

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.

Reports

A report is a class in modules/Reports/: either a single file modules/Reports/<Name>Report.php (class Modules\Reports\<Name>Report) or a folder modules/Reports/<Name>/<Name>Report.php (class Modules\Reports\<Name>\<Name>Report). It implements App\Contracts\ReportModuleInterface (getTitle, getDescription, getCategory, getSlug, generate(Request), hasDateFilter, hasCurrencyFilter, canExport); extending App\Reports\AbstractReport gives you the slug (from the title), a date filter, CSV export and date-range helpers. generate() returns columns, rows and optionally totals and chart. App\Services\ReportManager finds reports without registration; they are listed by category on Reports (/admin/reports, permission view_reports). The 27 built-in reports in modules/Reports/ are examples.

Dashboard widgets

A widget implements App\Contracts\WidgetModuleInterface (getTitle, getDescription, getColumns (1 to 4 quarters of the row), getWeight (lower comes first), getPermission (the staff permission needed to see it, or null), getCacheTtl (seconds, 0 for no cache), getData, render(array $data)). Widgets are not discovered: the built-in ones are registered on App\Services\WidgetManager in AppServiceProvider, and an addon registers its own from its service provider:

public function boot(): void
{
    $this->app->make(\App\Services\WidgetManager::class)->register('reviews', new ReviewsWidget());
}

A widget whose getData() or render() throws shows a short error in its box; the dashboard still loads.

Replacing a core service

SMS providers. An addon for a local SMS provider implements App\Contracts\SmsSender (send(string $to, string $message): bool, $to in E.164) and binds it in its service provider: $this->app->bind(\App\Contracts\SmsSender::class, MySender::class);. Phone verification (App\Contracts\PhoneVerifier) then sends six-digit codes of its own through it when Twilio Verify is not set up (ten minutes, five tries); with Twilio set up, Twilio is used as before.

Phone verification and ticket mail. The same way, an addon's service provider can bind its own App\Contracts\PhoneVerifier (enabled(), start(string $to), check(string $to, string $code)) to replace Twilio Verify and the SMS codes altogether, or its own App\Contracts\MailboxClientInterface (fetchMessages(TicketDepartment, int $limit = 25), markProcessed(TicketDepartment, string $uid), disconnect()) to read support departments' mail some other way than IMAP (App\Services\Mail\ImapMailboxClient, used by the ticket mail import).

Built-in modules

These ship with PNLCS and are registered in app/Providers/ModuleServiceProvider.php; their names cannot be taken by a third-party manifest.

Type Modules (name)
Gateways banktransfer, stripe, paypal, authorize (Authorize.Net), mollie, razorpay, tpay, iyzico
Servers custom, panelica, cpanel, plesk, directadmin, proxmox, hestiacp, aapanel, vultr, panelicalicense
Registrars namecheap, resellerclub, openprovider, enom, manual, hrd, ovh, domainnameapi
SSL gogetssl
Addons Company Lookup, KSeF, Project Management, Staff Board (modules/Addons/)
Reports 27 reports in modules/Reports/
Dashboard widgets 13 widgets in app/Widgets/