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
Runs after the detail page's whole template data was built.
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
Runs after the module's card definitions were built, before they reach the screen.
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
Runs after the module's tool list was built.
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
Runs after it was decided which capabilities the domain detail tabs show.
null, so check before calling anything on it.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
Runs after an add-on was added to a service.
0 means this is a domain add-on, which has no entry in the product catalogue.0 where there is none.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
Runs after an add-on's status changed.
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.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
Runs while a customer switches a usage metric on or off. Stopping it means nothing reaches the database or the module.
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
Runs after the metric state was written.
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
Runs when a customer opens the management page of their own service. Ownership and access checks are already behind you.
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
Runs when an administrator opens a service detail. It carries the same data as its customer-side twin; only the person looking differs.
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
Runs before a customer downloads a service file. All three flows pass through here: requirement attachment, software package and delivery file.
requirement, package or delivery.user_id, service_id. The flow adds file name, version and record id.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
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.
path (full path on disk), name (the saved name), link (outside address), cleanup.kind, user_id, service_id, the service record and product id.cleanup and only the file is removed afterwards, not the folder above it.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
Runs while a customer assigns a billing profile to a service, before anything is written. Domains pass through this gate too.
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
Runs once the assignment is saved. The gate is behind you and the value is written.
Hook::add('action:service.billing_profile_assigned', 10,
function ($service, $profileId, $profileName, $uid) {
Acme::syncAccounting((int) ($service['id'] ?? 0), $profileId);
});Pitfalls
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.
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 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.
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
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.