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 |
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,OKby default) andhttp_statusset 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>/callbackthen 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/ |