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

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

Getting this wrong takes the whole site down, so it’s worth understanding.modules_statuses.json is read from disk on every request. Your provider class comes from Composer’s autoloader, which php-fpm keeps in opcache. If a module is switched on before php-fpm can see its classes, nwidart tries to instantiate a provider that doesn’t exist and every request fails, including artisan and the admin panel, so you can’t even disable it from the UI.
Two rules avoid it:
1

Dump before enabling, never after

2

Reload php-fpm so the new autoloader takes effect

Without the reload, php-fpm serves the stale autoloader until it revalidates timestamps, which is up to 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

If the module directory is gone but still listed as enabled, remove its entry from modules_statuses.json by hand.

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
Disabling the module leaves the app working

When it doesn’t work