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

# Service Modules

> Provision and manage something people buy: a game server, a VPS, a hosting account, a domain, a licence key.

Someone checks out, core makes a `Service` row, and your module makes the real thing exist.

<Note>
  Read [Fundamentals](/extensions/tutorials/fundamentals) first if you haven't.
</Note>

## Generate it

```bash theme={null}
php artisan make:module AcmePanel --type=service
php artisan module:enable AcmePanelService
php artisan module:migrate AcmePanelService
php artisan module:doctor AcmePanelService
```

<Accordion title="What you get" icon="folder-tree">
  ```text theme={null}
  Modules/Services/AcmePanelService/
  ├── module.json                 manifest with permissions declared
  ├── app/
  │   ├── Providers/              service, route and event providers
  │   ├── Http/Controllers/       admin settings page
  │   ├── Models/AcmeCredential   encrypted API key
  │   └── Services/
  │       ├── ModuleServiceActions    provisioning, the important one
  │       ├── RenewService            renewal side effects
  │       ├── CancelService           cancellation
  │       ├── MarketCheckoutConfig    extra checkout fields
  │       ├── ServiceControlsService  customer panel tabs
  │       └── Actions/
  │           ├── CreateServerService
  │           ├── SuspendServerService
  │           ├── UnsuspendServerService
  │           └── DeleteServerService
  ├── database/migrations/
  └── routes/                     web, api, settings, panel
  ```
</Accordion>

Search for `TODO` and you'll find the four or five places that need your API calls.

## What core calls, and when

| When                       | Core calls                                          | Needed?                |
| -------------------------- | --------------------------------------------------- | ---------------------- |
| Someone pays               | `ModuleServiceActions::createServerForService()`    | Yes                    |
| Admin suspends             | `Actions\SuspendServerService::suspendServer()`     | Yes                    |
| Admin unsuspends           | `Actions\UnsuspendServerService::unsuspendServer()` | Yes                    |
| Terminate, or a chargeback | `Actions\DeleteServerService::deleteServer()`       | Yes                    |
| Renewal is paid            | `RenewService::onAfterRenewal()`                    | For renewals           |
| Customer cancels           | `CancelService::cancelService()`                    | For cancellation       |
| Checkout renders           | `MarketCheckoutConfig::getCheckoutFields()`         | Only if you need input |
| Customer opens the service | `ServiceControlsService::getServiceControls()`      | Only for a panel       |
| Upgrade is paid            | `UpgradeService::processUpgrade()`                  | Only if you support it |
| Downgrade is paid          | `DowngradeService::processDowngrade()`              | Only if you support it |
| Addon is bought            | `AddonService::applyAddon()`                        | Only if you support it |

<Warning>
  All of it is resolved by class name. Get a name wrong and it silently does nothing, which is what
  `module:doctor` is for.
</Warning>

## Provisioning

The one class you can't do without. It runs in a queued job after the payment clears.

```php app/Services/ModuleServiceActions.php theme={null}
public function createServerForService(Service $service, array $userConfig = []): void
{
    $result = CreateServerService::createServer([
        'service_id' => $service->id,
        'user' => $service->owner,
        'product' => $service->product,
        'config' => $userConfig,
    ]);

    if (! ($result['success'] ?? false)) {
        throw new \RuntimeException($result['error'] ?? 'Acme provisioning failed');
    }

    $service->update([
        'config' => array_merge($service->config ?? [], [
            'acme_panel_server_id' => $result['server_id'],
        ]),
        'status' => 'active',
        'provisioned_at' => now(),
    ]);
}
```

<Warning>
  **Throw on failure.** You're inside a queued job, so an exception gets you a retry. Returning a
  failure array doesn't, and the customer has already been charged. This is the single most expensive
  mistake you can make in a service module.
</Warning>

Also implement the idempotency guard, which the generator gives you:

```php theme={null}
public function hasExistingConfiguration(Service $service): bool
{
    return ! empty($service->config['acme_panel_server_id']);
}
```

Core checks this before provisioning. Without it, a retried job or a webhook race creates a second
server the customer never asked for.

## Suspend, unsuspend, terminate

Three static methods, each taking a service ID rather than a model.

```php app/Services/Actions/SuspendServerService.php theme={null}
public static function suspendServer(int $serviceId): array
{
    // ... call your API ...

    // Remote already gone? That counts as success.
    if ($response->notFound()) {
        return ['success' => true, 'remote_missing' => true];
    }

    if ($response->failed()) {
        throw new \RuntimeException('Acme suspend failed: '.$response->body());
    }

    return ['success' => true, 'message' => 'Server suspended'];
}
```

<Tip>
  That 404 handling matters more than it looks. Without it, terminating a service whose server someone
  deleted by hand gets stuck forever and an admin can never clear it off the books.
</Tip>

## Renewal

`BaseRenewService` already owns the billing side: billing cycles, configurable option pricing, due
dates, free trial conversion, admin suspension checks. You only handle the side effect.

```php app/Services/RenewService.php theme={null}
class RenewService extends BaseRenewService
{
    protected function onAfterRenewal(Service $service, string $billingCycle): void
    {
        if ($service->status !== 'suspended') {
            return;
        }

        UnsuspendServerService::unsuspendServer($service->id);
    }
}
```

For most modules that's genuinely the whole file.

## Cancellation

`BaseCancelService` handles the awkward parts. You supply two methods.

```php app/Services/CancelService.php theme={null}
class CancelService extends BaseCancelService
{
    protected function remoteResourceId(Service $service): ?string
    {
        return $service->config['acme_panel_server_id'] ?? null;
    }

    protected function deleteRemoteResource(Service $service, bool $force): array
    {
        return DeleteServerService::deleteServer($service->id, $force);
    }
}
```

<AccordionGroup>
  <Accordion title="Pending services cancel cleanly" icon="circle-check">
    If there's no server ID and the service is `pending`, it's marked terminated and the cancellation
    succeeds. Provisioning failed, so there's nothing to delete.

    Without this a customer can't cancel a service that never worked.
  </Accordion>

  <Accordion title="Active services with a missing ID throw" icon="triangle-exclamation">
    A missing ID on an active service is a data problem somebody needs to look at, not something to
    quietly terminate.
  </Accordion>

  <Accordion title="Failures leave the service alone" icon="rotate-left">
    So it can be retried, rather than ending up terminated locally with a live server.
  </Accordion>

  <Accordion title="Password verification is handled" icon="lock">
    It reads the raw attribute, because `$user->password` can be cast or hidden.
  </Accordion>
</AccordionGroup>

## Checkout fields

<Note>Optional. Delete the class if your product needs no customer input.</Note>

```php app/Services/MarketCheckoutConfig.php theme={null}
public static function getCheckoutFields(): array
{
    return [[
        'name' => 'hostname',
        'label' => 'Hostname',
        'type' => 'text',
        'required' => true,
    ]];
}

public static function getServiceConfigFields(): array
{
    return ['hostname'];   // which keys get saved onto Service.config
}
```

`validateCheckoutData()` has to return exactly `['validated' => [...], 'errors' => [...]]`, because
core reads both keys by name. The generator writes a working version.

## Customer panel

<Note>Optional.</Note>

Two halves that have to line up.

<CodeGroup>
  ```php ServiceControlsService.php theme={null}
  'tabs' => [
      ['label' => 'Console', 'href' => '/acme-panel/console', 'icon' => '<svg ...>'],
  ],
  'permissions' => [
      '/acme-panel/console' => 'Server Console',
  ],
  ```

  ```php routes/panel.php theme={null}
  Route::middleware([
          'auth', 'verified',
          ModuleServiceAuditLogMiddleware::class,
          CheckServicePermissions::class,
          ServiceRoutesMiddleware::class,
      ])
      ->prefix('service/{serviceId}')
      ->group(function () {
          Route::get('/acme-panel/console', [PanelController::class, 'console']);
      });
  ```
</CodeGroup>

<Warning>
  The path after `service/{serviceId}` has to match the tab `href` exactly, and your permission keys
  have to match those paths. That's how the permission filter and the tab list stay in sync.
</Warning>

Icons are inline SVG strings (Lucide markup), not component names.

<Info>
  This is only called when the service is `active`, and you must accept both arguments
  (`$serviceConfig, $serviceId`) even if you ignore them. Core always passes them.
</Info>

## Upgrades, downgrades, addons

<Note>Optional. All three follow the same pattern.</Note>

Core has already taken the money by the time you're called.

```php theme={null}
public function processUpgrade(Service $service, Invoice $invoice): bool
{
    // apply the change on your panel, update $service->config
    return true;
}
```

Return `true` and core renames the service, marks the invoice processed and notifies the customer.
Return `false` and none of that happens, so the customer keeps their old resources. That's the right
failure mode.

`processDowngrade()` is identical. `AddonService` has `applyAddon()` and `removeAddon()`, both taking
`(Service $service, array $addon, int $quantity = 1)`.

## Test it properly

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

Then walk it:

<Steps>
  <Step title="Buy it">
    Create a product bound to your module and buy it as a test user.
  </Step>

  <Step title="Check the remote side">
    The server exists and `Service.config` has its ID.
  </Step>

  <Step title="Run the lifecycle">
    Suspend, unsuspend, renew, cancel from admin. Check the remote state each time.
  </Step>

  <Step title="Re-run provisioning">
    Run the job again for the same service. Confirm you don't get a second server.
  </Step>

  <Step title="Break it on purpose">
    Wreck the credentials. Confirm the failure is loud, retried and visible to an admin.
  </Step>

  <Step title="Delete the server behind DezerX's back">
    Then terminate the service. It should still work.
  </Step>
</Steps>

<Tip>Steps 4 to 6 are the ones that catch real bugs.</Tip>

## Worth reading

<CardGroup cols={2}>
  <Card title="CPanelService" icon="code">
    Clearest small implementation.
  </Card>

  <Card title="LicenseService" icon="key">
    No external panel at all.
  </Card>

  <Card title="VirtfusionService" icon="hard-drive">
    VPS lifecycle with addons.
  </Card>

  <Card title="PterodactylService" icon="layer-group">
    The big one: per-hour billing, auto-stock, file manager.
  </Card>
</CardGroup>
