Skip to main content

Where modules live

The directory and the category in module.json have to agree. If they don’t, an imported module lands in the wrong place.
Your namespace has no group segment. A module at Modules/Services/AcmePanel is namespaced Modules\AcmePanel, not Modules\Services\AcmePanel.

module.json

module.json
string
required
StudlyCase. This is your namespace segment and what gets stored in Service.module.
string
required
Lowercased name. Used for config, translation and view keys.
string
required
Shown in the admin module list, and at checkout for gateways.
string
required
One of service, gateway, social, addons. Decides the group directory.
string[]
required
Fully qualified provider class names.
string
Friendly name for the UI. Always set it: without it the UI falls back through a chain that can end up showing your alias.
object
Admin permission declarations. See Admin permissions.
OAuth providers only. See the OAuth guide.

The service provider

Pick the base for your group and you’re done.
Both $name and $nameLower are optional. They’re derived from the namespace, so an empty class body works.

What the base does for you

1

Translations

From lang/, or from resources/lang/modules/{alias}/ if published.
2

Config

Merges config/config.php under the {alias} key, and publishes it to config/{alias}.php.
3

Views

Registers Blade views and a component namespace.
4

Migrations

Loads them from database/migrations.
5

Sub-providers

Registers your EventServiceProvider and RouteServiceProvider, skipping either if it doesn’t exist, so you don’t have to ship empty ones.
6

Schedules

Loads routes/console.php after the app has booted, which is when the scheduler is available.

Hooks you can override

Don’t schedule by overriding registerCommandSchedules(). Four modules used to do that, which meant their routes/console.php never loaded and their cron jobs were easy to lose in a refactor.Use moduleSchedule() or routes/console.php, not both, or the job runs twice.
The commands hook is moduleCommands(), not commands(), because ServiceProvider::commands() already exists with a different signature.
Four addons (Affiliates, Blog, KnowledgeBase, News) still use an older hand-written provider. Don’t copy those.

RouteServiceProvider

The base maps 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

Only web.php and api.php get mapped. The rest come in like this:
routes/web.php

Admin permissions

Declare them in module.json and core merges them into both places that matter: the map PermissionMiddleware uses, and the catalogue that draws the admin role editor.
module.json
Node keys get stored in the database (AdminRole.permissions JSON). Renaming one after release silently takes access away from every role that had it.
module:doctor tells you if you forget.
Without one, getCategoryForPermission() returns null, the role editor draws no checkbox, and the * wildcard won’t match. Only a superadmin could ever use it.
Prefix with an HTTP verb: "DELETE:/admin/acme-panel/backups/{id}".
You can’t redefine or shadow a core route’s permission.
Use it for something your controller checks itself with $user->hasPermission(...).
For one-off routes you can skip the manifest and name it inline:
Existing categories you can add to: gateway_settings (“Gateway & Service Settings”) and addon_module_settings (“Addon Module Settings”).
Every bundled module declares permissions this way, so copy whichever is closest. Modules/Gateways/StripeGateway is the simplest. Modules/Services/PterodactylService has several nodes including a method-specific one.

Admin settings with no frontend work

Extend ModuleSettingsController, 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

Every field also takes placeholder, help, max_length, full_width, copyable, and visible_when for conditional display.

Tabs

Declare groups 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.
Saving submits every tab, not just the visible one.

Test connection

Set test_url and the header gets a Test connection button that POSTs there. Return a back()->with('success'|'error', ...) and it surfaces as a toast.
Use url() here, not route(). route() throws when the module is disabled, which makes the definition impossible to build in a test.

Status cards

Sidebar panels for read-only facts. status_tone accepts success, warning, danger or neutral, and rows can be copyable.
Notices containing a URL get a Copy URL button automatically.

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()

Gateways must call this after saving or checkout keeps a stale list for five minutes.
Always ship a Test Connection action. It’s the difference between a five-minute setup and a support ticket.

Credentials

The column must be TEXT, not string, because ciphertext is much longer than what went in.
Only put encrypted on a brand new column. The cast uses Crypt::encrypt(), which serialises. The older credential models hand-roll Crypt::encryptString(), which doesn’t. Mixing them breaks decryption:
Convert an existing model only with a migration that re-encrypts the column first.

Migrations

Normal Laravel migrations in database/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:
Never change or drop a core column. Add your own, or use a side table with a foreign key so your module stays removable.

Events

Core fires 26 events from app/Events/. The useful ones: InvoicePaid, ServicePaymentCompleted, ServiceRenewed, ServiceSuspended, ServiceTerminated, ServiceCancelled, ServiceUpgradeCompleted, AddonPurchased, UserRegistered, TicketCreated.
Two mistakes that have both happened here:
Laravel’s base 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.
Extending EmptyGatewayEventServiceProvider gives you the override already.
Anything in 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.
Add implements ShouldQueue to your listeners unless they genuinely have to be synchronous. A listener that throws inline can break checkout for everyone.
All of these are post-events. There’s currently no way to veto or change an action before it happens.

Frontend pages

Module pages aren’t Vite entries. Each module has a register.ts that maps Inertia page names to dynamic imports.
Namespace every key with your module name so it can’t collide, and count the ../ carefully. It’s five levels up.
This is deliberate. Listing 100+ module .tsx files as Vite entries duplicates chart.js, monaco and xterm across parallel chunk graphs and blows up the build.
Your pages are bundled into the app’s own build, which has two consequences:
Vite finds every 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.
A module’s own 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 the module_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.
Go to Admin, Modules and flip the switch. Enabling runs the module’s migrations, clears the permission and checkout-gateway caches, and reloads the Octane workers once the response has gone out. There’s nothing else to run.
make:module leaves a new module disabled and regenerates Composer’s autoloader for you.

The autoloader trap

Getting this wrong takes the whole site down, so it’s worth understanding.A worker loads the module list and Composer’s autoloader together when it boots. If an enabled module’s provider class isn’t in the autoloader at that moment, nwidart tries to instantiate a class that doesn’t exist and the worker fails to boot. Every worker does the same, so every request fails, artisan included, and you can’t disable the module from the admin panel either.
Under Octane the trap springs at the next reload: the one an admin toggle triggers, a manual octane: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.
On a server that runs PHP-FPM instead of Octane, the order is: dump the autoloader, reload PHP-FPM, then enable. See Servers still on PHP-FPM.

If you have already broken it

If the module’s classes really are gone, artisan can’t boot to disable it. Switch it off in the database instead, then restart Octane again:
If you deleted the module’s folder, delete its row as well.

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.
1

Put it in the folder that matches its category

The folder name must be the module’s name, exactly. Make sure the user the site runs as owns it:
2

Regenerate the autoloader

3

Rebuild the frontend, if it has pages

Only needed when the module ships a resources/js/register.ts.
4

Reload Octane

Workers scan Modules/ only when they boot, so the module won’t appear in Admin, Modules until they reload.
5

Enable it

From Admin, Modules, which also runs its migrations.

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:
If a module with the same name ships in the new release, the release’s copy is kept and yours is dropped. Don’t patch a bundled module in place: copy it under a new name.
The updater sorts custom modules by name: ...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

Two ways to reload from a shell:
Or use Admin, System: Reload Octane reloads the workers, and Restart All also restarts the queue workers. Reloads are graceful, so requests in flight finish on the old workers first.
Run artisan as the user the site runs as, usually 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.
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.
When a server lacks the PHP extensions RoadRunner needs, the installer falls back to PHP-FPM. There, reload PHP-FPM instead of Octane. Its OPcache doesn’t re-check files, so changed PHP isn’t picked up until you do:
Use your PHP version in the service name. systemctl status dezerx-octane tells you which setup you have.

Code that’s safe in a long-lived worker

exit(), die() and $response->send() stop the worker, not just the request. To end a request early, throw the response instead:
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:
Inertia::share() called from a service provider works for the first request and then disappears, because Octane resets Inertia’s shared props between requests. Call it from a middleware’s handle() instead, so it runs every time.
A static property or a 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:
A static cache of database rows goes stale for the life of the worker. For data that rarely changes and is the same for everyone, like plans, settings or a price list, use RuntimeSnapshot:
The value stays in worker memory for at most 60 seconds, and every worker drops it as soon as the runtime version changes. Every admin save (any non-GET request under /admin/*) and every Setting write changes it, so saving your settings page refreshes it everywhere. A webhook, job or command that changes the data should call RuntimeSnapshot::bump() itself.Store arrays, not model instances, and never per-user data: the snapshot is shared by everyone the worker serves.
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.
With 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

It validates your manifest, your 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 enabled
Tables exist with your prefix
Settings page loads and saves
Credentials encrypted, column is TEXT
Failures throw or log at error
It works after php artisan octane:reload, on the first request and on the ones after it
Two customers in a row each see only their own data
Disabling the module leaves the app working

When it doesn’t work