> ## Documentation Index
> Fetch the complete documentation index at: https://docs.dezerx.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Fundamentals

> The parts every module shares. make:module writes all of this for you, so treat it as an explanation of what you were given.

## Where modules live

<CodeGroup>
  ```text Layout theme={null}
  Modules/Services/    provisionable products   category: "service"
  Modules/Gateways/    payment processors       category: "gateway"
  Modules/Providers/   OAuth logins             category: "social"
  Modules/Addons/      everything else          category: "addons"
  ```
</CodeGroup>

The directory and the `category` in `module.json` have to agree. If they don't, an imported module
lands in the wrong place.

<Warning>
  Your namespace has no group segment. A module at `Modules/Services/AcmePanel` is namespaced
  `Modules\AcmePanel`, **not** `Modules\Services\AcmePanel`.
</Warning>

## module.json

```json module.json theme={null}
{
    "name": "AcmePanelService",
    "alias": "acmepanelservice",
    "description": "Provision and manage Acme game servers.",
    "category": "service",
    "display": "Acme Panel",
    "providers": ["Modules\\AcmePanelService\\Providers\\AcmePanelServiceServiceProvider"],
    "hasSettings": true,
    "settingsUrl": "/admin/acme-panel/settings",
    "permissions": { "...": "see below" },
    "logo": "/modules-logos/acme-panel-service.png"
}
```

<ParamField path="name" type="string" required>
  StudlyCase. This is your namespace segment and what gets stored in `Service.module`.
</ParamField>

<ParamField path="alias" type="string" required>
  Lowercased `name`. Used for config, translation and view keys.
</ParamField>

<ParamField path="description" type="string" required>
  Shown in the admin module list, and at checkout for gateways.
</ParamField>

<ParamField path="category" type="string" required>
  One of `service`, `gateway`, `social`, `addons`. Decides the group directory.
</ParamField>

<ParamField path="providers" type="string[]" required>
  Fully qualified provider class names.
</ParamField>

<ParamField path="display" type="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.
</ParamField>

<ParamField path="permissions" type="object">
  Admin permission declarations. See [Admin permissions](#admin-permissions).
</ParamField>

<ParamField path="link_url, link_column, colors" type="mixed">
  OAuth providers only. See the [OAuth guide](/modules/creating-an-oauth-provider-module).
</ParamField>

## The service provider

Pick the base for your group and you're done.

<Tabs>
  <Tab title="Service">
    ```php theme={null}
    use App\Modules\ServiceModuleServiceProvider;

    class AcmePanelServiceServiceProvider extends ServiceModuleServiceProvider
    {
        protected string $name = 'AcmePanelService';
        protected string $nameLower = 'acmepanelservice';
    }
    ```
  </Tab>

  <Tab title="Gateway">
    ```php theme={null}
    use App\Services\Payment\Modules\GatewayModuleServiceProvider;

    class AcmeGatewayServiceProvider extends GatewayModuleServiceProvider
    {
        protected string $name = 'AcmeGateway';
        protected string $nameLower = 'acmegateway';
    }
    ```
  </Tab>

  <Tab title="OAuth provider">
    ```php theme={null}
    use App\Modules\OAuthProviderServiceProvider;

    class AcmeProviderServiceProvider extends OAuthProviderServiceProvider
    {
        protected ?string $socialiteDriver = 'acme';
        protected ?string $socialiteProvider = \SocialiteProviders\Acme\Provider::class;
    }
    ```
  </Tab>

  <Tab title="Addon">
    ```php theme={null}
    use App\Modules\AddonModuleServiceProvider;

    class LoyaltyRewardsServiceProvider extends AddonModuleServiceProvider
    {
    }
    ```
  </Tab>
</Tabs>

<Note>
  Both `$name` and `$nameLower` are optional. They're derived from the namespace, so an empty class
  body works.
</Note>

### What the base does for you

<Steps>
  <Step title="Translations">
    From `lang/`, or from `resources/lang/modules/{alias}/` if published.
  </Step>

  <Step title="Config">
    Merges `config/config.php` under the `{alias}` key, and publishes it to `config/{alias}.php`.
  </Step>

  <Step title="Views">
    Registers Blade views and a component namespace.
  </Step>

  <Step title="Migrations">
    Loads them from `database/migrations`.
  </Step>

  <Step title="Sub-providers">
    Registers your `EventServiceProvider` and `RouteServiceProvider`, skipping either if it doesn't
    exist, so you don't have to ship empty ones.
  </Step>

  <Step title="Schedules">
    Loads `routes/console.php` after the app has booted, which is when the scheduler is available.
  </Step>
</Steps>

### Hooks you can override

| Hook                                       | Use it for                                                      |
| ------------------------------------------ | --------------------------------------------------------------- |
| `moduleCommands(): array`                  | Artisan commands to register                                    |
| `moduleSchedule(Schedule $schedule): void` | Cron jobs you don't declare in `routes/console.php`             |
| `bootModule(): void`                       | Boot-time wiring: middleware aliases, listeners, Inertia shares |
| `registerModule(): void`                   | Container bindings                                              |

```php theme={null}
protected function moduleCommands(): array
{
    return [SyncAcmeStock::class];
}

protected function moduleSchedule(Schedule $schedule): void
{
    $schedule->command('acme:sync-stock')->everyFiveMinutes()->withoutOverlapping();
}
```

<Warning>
  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.
</Warning>

<Info>
  The commands hook is `moduleCommands()`, not `commands()`, because `ServiceProvider::commands()`
  already exists with a different signature.
</Info>

Four addons (Affiliates, Blog, KnowledgeBase, News) still use an older hand-written provider. Don't
copy those.

### RouteServiceProvider

```php theme={null}
use App\Modules\BaseModuleRouteServiceProvider;

class RouteServiceProvider extends BaseModuleRouteServiceProvider
{
    protected string $name = 'AcmePanelService';

    protected array $middlewareAliases = [
        'acme.feature' => CheckFeatureEnabled::class,
    ];
}
```

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

| File           | Loaded how                                    | For                                                 |
| -------------- | --------------------------------------------- | --------------------------------------------------- |
| `web.php`      | Mapped directly                               | Public and user routes, plus `require`s of the rest |
| `api.php`      | Mapped directly, prefixed `api`, named `api.` | Webhooks, stateless endpoints                       |
| `settings.php` | `require` from `web.php`                      | Admin pages under `/admin/...`                      |
| `panel.php`    | `require` from `web.php`                      | Customer pages under `/service/{serviceId}/...`     |
| `payment.php`  | `require` from `web.php`                      | Gateway create, success, cancel                     |
| `console.php`  | The provider, after boot                      | `Schedule::command(...)`                            |

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

```php routes/web.php theme={null}
require __DIR__.'/settings.php';
require __DIR__.'/panel.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.

```json module.json theme={null}
"permissions": {
    "category": "gateway_settings",
    "category_name": "Gateway & Service Settings",
    "nodes": {
        "/admin/acme-panel/settings": {
            "label": "Manage Acme Panel",
            "routes": ["/acme-panel/settings", "/acme-panel/test-connection"]
        }
    }
}
```

<Warning>
  Node keys get stored in the database (`AdminRole.permissions` JSON). Renaming one after release
  silently takes access away from every role that had it.
</Warning>

<AccordionGroup>
  <Accordion title="Routes leave off the /admin prefix" icon="link-slash">
    `module:doctor` tells you if you forget.
  </Accordion>

  <Accordion title="Every node needs a category" icon="folder-open">
    Without one, `getCategoryForPermission()` returns null, the role editor draws no checkbox, and the
    `*` wildcard won't match. Only a superadmin could ever use it.
  </Accordion>

  <Accordion title="Nodes can be method-specific" icon="code">
    Prefix with an HTTP verb: `"DELETE:/admin/acme-panel/backups/{id}"`.
  </Accordion>

  <Accordion title="Core always wins" icon="shield">
    You can't redefine or shadow a core route's permission.
  </Accordion>

  <Accordion title="A node with no routes is fine" icon="hand">
    Use it for something your controller checks itself with `$user->hasPermission(...)`.
  </Accordion>
</AccordionGroup>

For one-off routes you can skip the manifest and name it inline:

```php theme={null}
Route::get('/admin/acme-panel/settings', [SettingsController::class, 'index'])
    ->defaults('permission', '/admin/acme-panel/settings');
```

Existing categories you can add to: `gateway_settings` ("Gateway & Service Settings") and
`addon_module_settings` ("Addon Module Settings").

<Tip>
  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.
</Tip>

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

```php theme={null}
class SettingsController extends ModuleSettingsController
{
    protected function settings(): array
    {
        $c = AcmeCredential::first();

        return ['url' => $c?->url ?? '', 'api_key' => $c?->api_key ?? ''];
    }

    protected function settingsDefinition(array $settings): array
    {
        return [
            'title' => 'Acme Panel',
            'description' => 'Where to reach your Acme install.',
            'update_url' => url('/admin/acme-panel/settings'),
            'test_url' => url('/admin/acme-panel/test-connection'),
            'fields' => [
                ['name' => 'url', 'type' => 'url', 'label' => 'API URL', 'required' => true],
                [
                    'name' => 'api_key',
                    'type' => 'password',
                    'label' => 'API Key',
                    'required' => true,
                    'configured' => $settings['api_key'] !== '',
                ],
            ],
        ];
    }
}
```

### Field types

| Type                   | Notes                                                                      |
| ---------------------- | -------------------------------------------------------------------------- |
| `text`, `email`, `url` | Plain inputs                                                               |
| `password`             | Reveal toggle, and shows "Stored" when `configured` is true                |
| `number`               | Takes `min`, `max`, `step`                                                 |
| `textarea`             | Takes `rows`, renders monospace, full width                                |
| `select`               | Takes `options` as `[['value' => ..., 'label' => ...]]`                    |
| `toggle`               | Boolean switch                                                             |
| `readonly`             | Display only, with a copy button. Good for generated IDs and callback URLs |

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.

```php theme={null}
'groups' => [
    ['key' => 'connection', 'label' => 'Connection', 'description' => 'How to reach the panel.'],
    ['key' => 'billing', 'label' => 'Billing'],
],
'fields' => [
    ['name' => 'url', 'type' => 'url', 'label' => 'API URL', 'group' => 'connection'],
    ['name' => 'markup', 'type' => 'number', 'label' => 'Markup %', 'group' => 'billing', 'min' => 0],
],
```

<Note>Saving submits every tab, not just the visible one.</Note>

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

```php theme={null}
'test_url' => url('/admin/acme-panel/test-connection'),
'test_label' => 'Check API access',   // optional
```

<Warning>
  Use `url()` here, not `route()`. `route()` throws when the module is disabled, which makes the
  definition impossible to build in a test.
</Warning>

### Status cards

Sidebar panels for read-only facts. `status_tone` accepts `success`, `warning`, `danger` or
`neutral`, and rows can be `copyable`.

```php theme={null}
'status_cards' => [[
    'title' => 'Connection',
    'status' => $settings['api_key'] ? 'Configured' : 'Not configured',
    'status_tone' => $settings['api_key'] ? 'success' : 'neutral',
    'rows' => [
        ['label' => 'Webhook URL', 'value' => url('/api/acme/webhook'), 'copyable' => true],
    ],
]],
```

Notices containing a URL get a Copy URL button automatically.

### Two helpers you get for free

<CardGroup cols={2}>
  <Card title="resolveSecret()" icon="key">
    `resolveSecret($request, 'api_key', $stored)` keeps the saved secret when the form posts an empty
    password box.
  </Card>

  <Card title="flushPaymentGatewayCache()" icon="broom">
    **Gateways must call this after saving** or checkout keeps a stale list for five minutes.
  </Card>
</CardGroup>

<Tip>
  Always ship a Test Connection action. It's the difference between a five-minute setup and a support
  ticket.
</Tip>

## Credentials

```php theme={null}
class AcmeCredential extends Model
{
    protected $table = 'acme_credentials';
    protected $fillable = ['url', 'api_key'];
    protected $casts = ['api_key' => 'encrypted'];
}
```

The column must be `TEXT`, not `string`, because ciphertext is much longer than what went in.

<Warning>
  **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:

  ```text theme={null}
  encryptString then decryptString : fine
  encryptString then decrypt       : throws
  ```

  Convert an existing model only with a migration that re-encrypts the column first.
</Warning>

## 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:

```php theme={null}
if (! Schema::hasColumn('users', 'acme_id')) {
    Schema::table('users', fn (Blueprint $t) => $t->string('acme_id')->nullable());
}
```

<Warning>
  Never change or drop a core column. Add your own, or use a side table with a foreign key so your
  module stays removable.
</Warning>

## Events

Core fires 25 events from `app/Events/`. The useful ones: `InvoicePaid`, `ServicePaymentCompleted`,
`ServiceRenewed`, `ServiceSuspended`, `ServiceTerminated`, `ServiceCancelled`,
`ServiceUpgradeCompleted`, `AddonPurchased`, `UserRegistered`, `TicketCreated`.

```php theme={null}
protected $listen = [
    \App\Events\ServicePaymentCompleted::class => [SendAcmeWelcome::class],
];
```

Two mistakes that have both happened here:

<AccordionGroup>
  <Accordion title="Keep configureEmailVerification() as a no-op" icon="envelope">
    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.

    ```php theme={null}
    protected function configureEmailVerification(): void {}
    ```

    Extending `EmptyGatewayEventServiceProvider` gives you the override already.
  </Accordion>

  <Accordion title="Never list a core listener in your $listen" icon="copy">
    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.
  </Accordion>
</AccordionGroup>

Add `implements ShouldQueue` to your listeners unless they genuinely have to be synchronous. A
listener that throws inline can break checkout for everyone.

<Info>
  All of these are post-events. There's currently no way to veto or change an action before it
  happens.
</Info>

## Frontend pages

Module pages aren't Vite entries. Each module has a `register.ts` that maps Inertia page names to
dynamic imports.

<CodeGroup>
  ```ts register.ts theme={null}
  import { registerModulePages } from '../../../../../resources/js/modulePages';

  registerModulePages({
      'AcmePanel/Console': () => import('./Pages/Console.tsx'),
  });
  ```

  ```php Controller theme={null}
  return Inertia::render('AcmePanel/Console', ['data' => $data]);
  ```
</CodeGroup>

Namespace every key with your module name so it can't collide, and count the `../` carefully. It's
five levels up.

<Info>
  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.
</Info>

## Enabling a module

`make:module` leaves a new module **disabled** and regenerates Composer's autoloader for you. Turn it
on as a separate step:

```bash theme={null}
php artisan module:enable AcmePanelService
php artisan module:migrate AcmePanelService
```

### The autoloader trap

<Warning>
  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.
</Warning>

Two rules avoid it:

<Steps>
  <Step title="Dump before enabling, never after">
    ```bash theme={null}
    composer dump-autoload
    ```
  </Step>

  <Step title="Reload php-fpm so the new autoloader takes effect">
    ```bash theme={null}
    sudo systemctl reload php8.4-fpm
    ```

    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.
  </Step>
</Steps>

`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

```bash theme={null}
composer dump-autoload
sudo systemctl reload php8.4-fpm
```

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

## Checking your work

```bash theme={null}
php artisan module:doctor AcmePanelService
```

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:

<Check>`module:list` shows it enabled</Check>
<Check>Tables exist with your prefix</Check>
<Check>Settings page loads and saves</Check>
<Check>Credentials encrypted, column is `TEXT`</Check>
<Check>Failures throw or log at `error`</Check>
<Check>Disabling the module leaves the app working</Check>

## When it doesn't work

| What you see                                  | What it usually is                                                                 |
| --------------------------------------------- | ---------------------------------------------------------------------------------- |
| Feature does nothing at all                   | Class name is wrong. Run `module:doctor`.                                          |
| `Class not found` after adding a file         | Needs `composer dump-autoload`                                                     |
| Inertia page not found                        | `register.ts` key doesn't match `Inertia::render()`, or the `../` count is off     |
| Admin route 403s for a real admin             | Permission node has no category, or `routes` included the `/admin` prefix          |
| Permission can't be ticked in the role editor | Same: no category, so it's superadmin only                                         |
| Gateway missing at checkout                   | `getConfig()['enabled']` is false, or you didn't call `flushPaymentGatewayCache()` |
| Module not found at all                       | Directory doesn't match `category`, or invalid `module.json`                       |
| "Provisioned" but nothing exists              | Something caught the error and returned success. Throw instead.                    |
| Duplicate emails or log rows                  | Missing `configureEmailVerification()`, or you listed a core listener              |
| Cron job stopped                              | You overrode `registerCommandSchedules()` instead of using `moduleSchedule()`      |
