Where modules live
category in module.json have to agree. If they don’t, an imported module
lands in the wrong place.
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.
mixed
OAuth providers only. See the OAuth guide.
The service provider
Pick the base for your group and you’re done.- Service
- Gateway
- OAuth provider
- Addon
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
The commands hook is
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
Only
web.php and api.php get mapped. The rest come in like this:
routes/web.php
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.
module.json
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
Without one,
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
Prefix with an HTTP verb:
"DELETE:/admin/acme-panel/backups/{id}".Core always wins
Core always wins
You can’t redefine or shadow a core route’s permission.
A node with no routes is fine
A node with no routes is fine
Use it for something your controller checks itself with
$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
Every field also takes
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.
Saving submits every tab, not just the visible one.
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()
Gateways must call this after saving or checkout keeps a stale list for five minutes.
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 25 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
Laravel’s base Extending
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
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.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 aregister.ts that maps Inertia page names to
dynamic imports.
../ 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.Enabling a module
make:module leaves a new module disabled and regenerates Composer’s autoloader for you. Turn it
on as a separate step:
The autoloader trap
Two rules avoid it:1
Dump before enabling, never after
2
Reload php-fpm so the new autoloader takes effect
opcache.revalidate_freq seconds (60 on this server). During that window the site errors.make:module --enable does the dump, verifies the provider loads before flipping the switch, and
warns you to reload php-fpm. It’s still safer to enable as a separate step on a live server.
If you have already broken it
modules_statuses.json by hand.
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 enabledTables exist with your prefix
Settings page loads and saves
Credentials encrypted, column is
TEXTFailures throw or log at
errorDisabling the module leaves the app working

