Where modules live
category in module.json have to agree. If they don’t, an imported module
lands in the wrong place.
module.json
Service.module.name. Used for config, translation and view keys.service, gateway, social, addons. Decides the group directory.The service provider
Pick the base for your group and you’re done.- Service
- Gateway
- OAuth provider
- Addon
$name and $nameLower are optional. They’re derived from the namespace, so an empty class
body works.What the base does for you
Translations
lang/, or from resources/lang/modules/{alias}/ if published.Config
config/config.php under the {alias} key, and publishes it to config/{alias}.php.Views
Migrations
database/migrations.Sub-providers
EventServiceProvider and RouteServiceProvider, skipping either if it doesn’t
exist, so you don’t have to ship empty ones.Schedules
routes/console.php after the app has booted, which is when the scheduler is available.Hooks you can override
moduleCommands(), not commands(), because ServiceProvider::commands()
already exists with a different signature.RouteServiceProvider
routes/api.php under the api group with an api. name prefix, and each file in
$webRouteFiles (default ['web.php']) under web. Missing files are skipped. Set
$hasApiRoutes = false if you ship no API routes.
Route files
web.php and api.php get mapped. The rest come in like this:
Admin permissions
Declare them inmodule.json and core merges them into both places that matter: the map
PermissionMiddleware uses, and the catalogue that draws the admin role editor.
Routes leave off the /admin prefix
Routes leave off the /admin prefix
module:doctor tells you if you forget.Every node needs a category
Every node needs a category
getCategoryForPermission() returns null, the role editor draws no checkbox, and the
* wildcard won’t match. Only a superadmin could ever use it.Nodes can be method-specific
Nodes can be method-specific
"DELETE:/admin/acme-panel/backups/{id}".Core always wins
Core always wins
A node with no routes is fine
A node with no routes is fine
$user->hasPermission(...).gateway_settings (“Gateway & Service Settings”) and
addon_module_settings (“Addon Module Settings”).
Admin settings with no frontend work
ExtendModuleSettingsController, describe your fields, and core builds the page. No React needed.
It renders the shared admin/ModuleSettings page and pulls your display name, logo and module type
straight from module.json, so the header and back link match your module.
Field types
placeholder, help, max_length, full_width, copyable, and
visible_when for conditional display.
Tabs
Declaregroups and tag fields with group to split a long form into tabs. Tabs only appear when
more than one group has visible fields, so a small module stays a single page.
Test connection
Settest_url and the header gets a Test connection button that POSTs there. Return a
back()->with('success'|'error', ...) and it surfaces as a toast.
Status cards
Sidebar panels for read-only facts.status_tone accepts success, warning, danger or
neutral, and rows can be copyable.
Two helpers you get for free
resolveSecret()
resolveSecret($request, 'api_key', $stored) keeps the saved secret when the form posts an empty
password box.flushPaymentGatewayCache()
Credentials
TEXT, not string, because ciphertext is much longer than what went in.
Migrations
Normal Laravel migrations indatabase/migrations/. Prefix your tables with the module name, since
every module shares one database.
Touching a core table is allowed. Make the column nullable and the migration idempotent, because
modules get enabled and disabled over and over:
Events
Core fires 26 events fromapp/Events/. The useful ones: InvoicePaid, ServicePaymentCompleted,
ServiceRenewed, ServiceSuspended, ServiceTerminated, ServiceCancelled,
ServiceUpgradeCompleted, AddonPurchased, UserRegistered, TicketCreated.
Keep configureEmailVerification() as a no-op
Keep configureEmailVerification() as a no-op
EventServiceProvider registers SendEmailVerificationNotification from every
provider that doesn’t override it, and each copy is another email to the same person. Five
gateways were missing this and new users got six verification emails each.EmptyGatewayEventServiceProvider gives you the override already.Never list a core listener in your $listen
Never list a core listener in your $listen
app/Listeners is already picked up by event discovery, so listing it again registers
it twice. Pterodactyl and Pelican both did this with App\Listeners\LogUserRegistration, and
every signup was written to the audit log three times.implements ShouldQueue to your listeners unless they genuinely have to be synchronous. A
listener that throws inline can break checkout for everyone.
Frontend pages
Module pages aren’t Vite entries. Each module has aregister.ts that maps Inertia page names to
dynamic imports.
../ carefully. It’s
five levels up.
.tsx files as Vite entries duplicates chart.js, monaco and
xterm across parallel chunk graphs and blows up the build.Pages ship when the frontend is rebuilt
Pages ship when the frontend is rebuilt
Modules/*/*/resources/js/register.ts at build time. A new page, or a change to
an existing one, reaches the site after npm run build. Octane doesn’t need a reload for this,
because the workers pick up the new build manifest on their own.Only core's packages are available
Only core's packages are available
package.json is never installed. Your pages can import anything in the root
package.json (React, Inertia, lucide-react, framer-motion, chart.js, Monaco, xterm and the
rest) and core’s components, hooks and contexts through the @/ alias. Don’t add a package to the
root package.json for your module either, because every update replaces it.Composer is the same: updates run composer install against core’s lock file, so a package your
module’s composer.json requires won’t be installed on customer servers.Enabling a module
Whether a module is on or off is stored in themodule_statuses database table.
modules_statuses.json is only read once, to fill an empty table on a fresh install. Editing it by
hand does nothing. Use the admin panel or module:enable and module:disable.- Admin panel
- Command line
make:module leaves a new module disabled and regenerates Composer’s autoloader for you.
The autoloader trap
Under Octane the trap springs at the next reload: the one an admin toggle triggers, a manualoctane:reload, a deploy or a reboot. One rule avoids it. Dump the autoloader before you enable the
module, never after:
make:module --enable does the dump and checks that the provider loads before it flips the switch.
You still have to reload Octane yourself.
If you have already broken it
Installing a module
For a module you didn’t generate on this server, from the marketplace or from a colleague. There’s no upload button in the admin panel: modules are installed on the server itself.Put it in the folder that matches its category
name, exactly. Make sure the user the site runs as owns it:Regenerate the autoloader
Rebuild the frontend, if it has pages
resources/js/register.ts.Reload Octane
Modules/ only when they boot, so the module won’t appear in Admin, Modules
until they reload.Enable it
Updates keep your modules
An update replaces the application directory, then puts back every module that doesn’t ship with the new release. It rebuilds the autoloader and the frontend afterwards, and enabled state lives in the database, so there’s nothing for you to run. Two rules decide where a module ends up:Bundled names win
Bundled names win
The name suffix picks the folder
The name suffix picks the folder
...Service into Services, ...Gateway into
Gateways, ...Provider into Providers, and everything else into Addons. An addon called
UptimeService would be moved into Modules/Services/ on the next update.make:module adds the right suffix for you. Keep it when you rename.Running under Octane
DezerX serves HTTP through Laravel Octane on RoadRunner, with NGINX in front. Each worker boots the application once and then handles thousands of requests with it. That’s where most of the speed comes from, and it changes two things for module authors: when your changes go live, and what your code may keep between requests.Making changes live
www-data. From another account octane:reload
can report “Octane server is not running” when it is, and files that root creates under storage/
can’t be written by the site afterwards.In development
In development
composer dev:octane starts Octane with --watch, which reloads by itself when PHP under
Modules/*/*/app or Modules/*/*/routes changes. Older modules keep Services/ and Http/ at
the module root, outside what it watches, so reload by hand after editing those.Servers still on PHP-FPM
Servers still on PHP-FPM
systemctl status dezerx-octane tells you which setup
you have.Code that’s safe in a long-lived worker
Never exit() or die()
Never exit() or die()
exit(), die() and $response->send() stop the worker, not just the request. To end a request
early, throw the response instead:Boot code runs once per worker
Boot code runs once per worker
boot(), bootModule(), registerModule() and the top level of your route files run when a
worker starts, not on every request. Registering routes, listeners, middleware and bindings there
is right. Reading the current user, the request, the session or a setting there is wrong: you get
whatever the first request had, for the life of the worker.Per-request work belongs in middleware. Register it once from your provider, the way Affiliates
captures referral codes:No request state in statics or singletons
No request state in statics or singletons
singleton() binding lives as long as the worker. Put the current user, a
customer’s service or anything from the request in one, and the next customer gets it. Bind
services that hold per-request state with scoped(), which Octane throws away after every
request:Register listeners once, never per request
Register listeners once, never per request
Event::listen(), View::composer() and afterResolving() add to dispatchers that outlive the
request. Called from a controller or middleware, they pile up, run several times and make every
later request slower. Register them in your provider.GUEST_PAGE_CACHE=true, signed-out visitors to a short list of public routes get a cached copy
of the page. The list is in config/page_cache.php and already includes some module pages
(news.index, knowledgebase.index, custom-pages.show, status.public). Your routes are only
cached if they’re on it. A route on the list must render the same for every guest, with nothing
taken from the visitor’s IP, cookies or query string, and no side effects such as view counters.Checking your work
composer.json, that your provider loads, your permissions block,
and every convention class core will look for: does it exist, is it static or instance, does it take
the arguments core passes, does it implement the right interface. It exits non-zero on errors so it
works in CI. --strict also fails on warnings.
Then check by hand:
module:list shows it enabledTEXTerrorphp artisan octane:reload, on the first request and on the ones after it
