Service Detail Hooks

1 views Markdown

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
$dataarrayrefAll 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
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
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
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
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
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
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
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
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
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
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
mixedA 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
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
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|nullA 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
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
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.

Was this helpful?

Thanks for your feedback!

Still Need Help?

Our support team is here around the clock for anything you can't find above.