# Service Detail Hooks

https://dev.wisecp.com/es/service-detail-hooks

The eight hooks behind what a customer sees on a service detail: the page data, the cards, the tools, add-ons and metrics.

## Overview

The service detail is the screen a customer looks at most, and much of it comes from **the module**: the cards, the tools, the capabilities. The filters here catch that output before it reaches the screen.

The second group is add-ons. An add-on has a **status flow of its own** and moves separately from the parent service: bought on its own, invoiced on its own, suspended on its own.

## Reference

### Changing the detail page data

filterservice.detail_data

`ClientServices` the whole page

Runs after the detail page's whole template data was built.

Parameters 2

$dataarrayref**All** the data going to the page. The widest place to step in: add your own key without breaking the ones there.

$servicearrayThe service in view.

Return 1

voidThe value changes **by reference**; the return is not read.

Listener PHP

```php
Hook::add('filter:service.detail_data', 10, function (&$data, $service) {
    // Add your own data without disturbing the keys already there.
    $data['acme_health'] = Acme::health((int) ($service['id'] ?? 0));
});
```

### Changing the dashboard cards

filterservice.dashboard.cards

`ServerModule` module output

Runs after the module's card definitions were built, before they reach the screen.

Parameters 2

$cardsarrayrefThe card definitions. You can add, drop or reorder them.

$moduleServerModuleThe service's module object. Use it to tell modules apart; a rule may not suit them all.

Return 1

voidThe value changes **by reference**; the return is not read.

Listener PHP

```php
Hook::add('filter:service.dashboard.cards', 10, function (&$cards, $module) {
    // Put your card where it belongs rather than at the end.
    array_splice($cards, 1, 0, [Acme::usageCard()]);
});
```

### Changing the tool list

filterservice.dashboard.tools

`ServerModule` a grouped list

Runs after the module's tool list was built.

Parameters 2

$toolsarrayrefThe grouped tool list: each group carries its own details and tools. It is not a flat list; you reach into the group.

$moduleServerModuleThe service's module object.

Return 1

voidThe value changes **by reference**; the return is not read.

Listener PHP

```php
Hook::add('filter:service.dashboard.tools', 10, function (&$tools, $module) {
    // The list IS GROUPED: reach the tools inside a group.
    foreach ($tools as &$group)
        $group['tools'] = array_filter($group['tools'] ?? [],
            fn ($t) => ($t['key'] ?? '') !== 'rebuild');
});
```

### Changing the domain capabilities

filterservice.detail_tabs_capabilities

`ClientDomains` it opens the tabs

Runs after it was decided which capabilities the domain detail tabs show.

Parameters 3

$capabilitiesarrayrefThe capability marks: DNS records, mail forwarding and the like. Every mark you write here is **carried back** to the screen's matching key: switching one off hides that tab.

$servicearrayThe domain service in view.

$moduleobject|nullThe provider module — it **can be empty**. On a domain with no module it arrives as `null`, so check before calling anything on it.

Return 1

voidThe value changes **by reference**; the return is not read.

Listener PHP

```php
Hook::add('filter:service.detail_tabs_capabilities', 10,
    function (&$capabilities, $service, $module) {
        if ($module === null) return;                 // a domain with no module

        // Switching a mark off hides that tab on the screen.
        $capabilities['has_email_forwarding'] = Acme::mailAllowed($service);
    });
```

### Learning that an add-on opened

actionservice.addon.created

`Services` a separate invoice

Runs after an add-on was added to a service.

Parameters 5

$addonRecordIdintThe id of the created add-on record.

$serviceIdintThe id of the parent service.

$addonIdintThe id of the add-on product. `0` means this is a **domain add-on**, which has no entry in the product catalogue.

$invoiceIdintThe id of the raised invoice, or `0` where there is none.

$totalAmountfloatThe total charged for the add-on.

Return 1

voidThe return is ignored.

Listener PHP

```php
Hook::add('action:service.addon.created', 10,
    function ($addonRecordId, $serviceId, $addonId, $invoiceId, $totalAmount) {
        // An addonId of 0 marks a DOMAIN add-on, not a product.
        if ($addonId === 0) return;

        Crm::addonSold($serviceId, $addonId, (float) $totalAmount);
    });
```

### Following an add-on status

actionservice.addon.status_changed

`Services` one array parameter

Runs after an add-on's status changed.

Parameters 1

$payloadarrayA single array: `service`, `addon`, `addon_id`, `old_status`, `new_status`, `user_id`. An add-on's status is **independent** of the parent: one can be suspended while the other runs.

Return 1

voidThe return is ignored.

Listener PHP

```php
Hook::add('action:service.addon.status_changed', 10, function ($payload) {
    // ONE array arrives; the old and new status sit inside it.
    if (($payload['new_status'] ?? '') === 'suspended')
        Acme::addonOff((int) ($payload['addon_id'] ?? 0));
});
```

### Stopping a metric switch

gateservice.metric_toggle

`ClientServices` nothing is written

Runs while a customer switches a usage metric on or off. Stopping it means nothing reaches **the database or the module**.

Parameters 4

$servicearrayThe service record.

$metricKeystringThe metric key. Defined in the product's metric settings.

$enableboolThe state being asked for.

$labelstringThe metric's display label. Handed to you ready for your error message.

Return 1

stringA non-empty string **stops** the switch; the text is thrown as the error.

Listener PHP

```php
Hook::add('gate:service.metric_toggle', 10,
    function ($service, $metricKey, $enable, $label) {
        // The label arrives ready: write the message with the name they see.
        if (!$enable && Acme::metricRequired($metricKey))
            return $label . ' cannot be switched off.';

        return null;
    });
```

### Following a metric switch

actionservice.metric_toggled

`ClientServices` it landed

Runs after the metric state was written.

Parameters 3

$servicearrayThe service record.

$metricKeystringThe metric key.

$enableboolThe new state that was written.

Return 1

voidThe return is ignored.

Listener PHP

```php
Hook::add('action:service.metric_toggled', 10, function ($service, $metricKey, $enable) {
    // A metric switched off can still be billed: set your own counter.
    Acme::metricState((int) ($service['id'] ?? 0), $metricKey, (bool) $enable);
});
```

### Following a customer opening the detail page

actionservice.detail.viewed

`website/services` on every opening

Runs when a customer opens the management page of their own service. Ownership and access checks are already behind you.

Parameters 2

$servicearrayThe loaded service record.

$serviceIdintThe id of the service.

Return 1

voidThe return is ignored. To change what the page shows, use the detail data filter rather than this hook.

Listener PHP

```php
Hook::add('action:service.detail.viewed', 10, function ($service, $serviceId) {
    // Keep it light: the customer is waiting for the page.
    Acme::touchLastSeen($serviceId);
});
```

### Following an admin opening the detail page

actionservice.viewed

`admin/services` on every opening

Runs when an administrator opens a service detail. It carries the **same data** as its customer-side twin; only the person looking differs.

Parameters 2

$servicearrayThe loaded service record.

$service_idintThe id of the service.

Return 1

voidThe return is ignored.

Listener PHP

```php
Hook::add('action:service.viewed', 10, function ($service, $service_id) {
    // Pull the live status from the remote panel ahead of time.
    Acme::prefetchStatus($service_id);
});
```

### Stopping a file download

gateservice.file_download

`website/services` three flows

Runs before a customer downloads a service file. All three flows pass through here: requirement attachment, software package and delivery file.

Parameters 2

$kindstringWhich flow: `requirement`, `package` or `delivery`.

$ctxarrayContext. Shared: `user_id`, `service_id`. The flow adds file name, version and record id.

Return 1

mixed**A filled return blocks the download**: the answer becomes a 403 at once. The trap: this endpoint returns a raw file, so your return **never reaches the customer**. Unlike other gates no reason appears on screen; record it yourself. An empty return lets the download go on.

Listener PHP

```php
Hook::add('gate:service.file_download', 10, function ($kind, $ctx) {
    if ($kind !== 'package') return null;

    // Block when the quota is used up; no reason reaches the screen, so log it.
    if (Acme::quotaExceeded((int) ($ctx['user_id'] ?? 0))) {
        Acme::log('download quota used up', $ctx);
        return true;
    }

    return null;
});
```

### Changing which file is served

filterservice.file_download.source

`website/services` passed by link

Runs before the file is handed to the stream. You can build a package with install-specific keys inside it, or send the download to an address of your own.

Parameters 2

$sourcearrayby linkWhat will be served: `path` (full path on disk), `name` (the saved name), `link` (outside address), `cleanup`.

$ctxarrayby linkContext: `kind`, `user_id`, `service_id`, the service record and product id.

Return 1

voidThe return is ignored; you write over the source. A filled path is sent; an empty path with an outside address redirects there; neither gives a 404. Set `cleanup` and **only the file** is removed afterwards, not the folder above it.

Listener PHP

```php
Hook::add('filter:service.file_download.source', 10, function (&$source, &$ctx) {
    if (($ctx['kind'] ?? '') !== 'package') return;

    // Build a package with the install key inside, then have it removed.
    $source['path']    = Acme::buildPackage((int) ($ctx['service_id'] ?? 0));
    $source['name']    = 'acme-setup.zip';
    $source['cleanup'] = true;
});
```

### Stopping a billing profile assignment

gateservice.billing_profile_assign

`ClientServiceBilling` before the write

Runs while a customer assigns a billing profile to a service, before anything is written. Domains pass through this gate too.

Parameters 3

$servicearrayThe service or domain record.

$profileIdintThe id of the profile being assigned. A **zero** means the override is being dropped and the account default restored.

$uidintThe account that owns both the profile and the service.

Return 1

string|null**A non-empty text blocks the assignment** and the text is shown to the customer as the error. An empty return lets it carry on.

Listener PHP

```php
Hook::add('gate:service.billing_profile_assign', 10,
    function ($service, $profileId, $uid) {
        // Do not allow profile changes while an account is frozen.
        if (Acme::frozen($uid)) return 'Your account is under review, so this cannot change.';

        return null;
    });
```

### Following a billing profile assignment

actionservice.billing_profile_assigned

`ClientServiceBilling` after the write

Runs once the assignment is saved. The gate is behind you and the value is written.

Parameters 4

$servicearrayThe service or domain record.

$profileIdintThe assigned profile; a zero means the default came back.

$profileNamestringThe visible name: a company or person, or "Default" when the override was dropped.

$uidintThe owning account.

Return 1

voidThe return is ignored.

Listener PHP

```php
Hook::add('action:service.billing_profile_assigned', 10,
    function ($service, $profileId, $profileName, $uid) {
        Acme::syncAccounting((int) ($service['id'] ?? 0), $profileId);
    });
```

## Pitfalls

> **The tool list arrives grouped**
> 
> The tool filter hands you **a list of groups**, not a flat list, with each group holding its own tools. A listener walking it directly finds no tools, because every element it holds is a **group**.

> **The module can be empty on the capability filter**
> 
> On the domain capability filter the third parameter **can be empty**: some domains have no module. Calling a method without checking takes the page down. Every mark you write here is also **carried back** to the screen, so switching one off makes that tab vanish.

> **An add-on is independent of its parent**
> 
> An add-on carries its own status, its own invoice and its own suspend flow. The parent can be running while the add-on is suspended, or the other way round. Reading the parent's status and assuming the add-on is open leaves **an unpaid capability** switched on.

> **A zero add-on id means a domain**
> 
> Where the product id on the add-on event is `0`, this is a **domain add-on** (DNS management, privacy, forwarding) with no entry in the product catalogue. A listener reaching for a product finds **nothing** here.

## Related Articles

- [Service Module Hooks](https://dev.wisecp.com/en/service-module-hooks)
- [Service Status Hooks](https://dev.wisecp.com/en/service-status-hooks)
- [Hooks on the Customer Site](https://dev.wisecp.com/en/hooks-on-the-customer-site)
