Domain Forwarding Hooks
The eight hooks carrying an arriving visitor and post somewhere else, plus the add-on purchase opening those surfaces.
Overview
Forwarding comes in two kinds and both sit behind the same add-on: one carries whoever arrives at the address, the other carries the post.
They differ in number. An address forward is one per domain, while mail forwarding is a list. That difference shows in the parameters: one carries a single record, the other source and target pairs.
Reference
Stopping an address forward
Runs before the forward is set at the provider or cleared from it.
protocol (http/https), target (the target without a scheme, trailing slash trimmed), method (301/302). On a clear it arrives as an empty array.save or cancel.Hook::add('gate:domain.url_forwarding_change', 10,
function ($service, $fwd, $verb) {
if ($verb === 'cancel') return null; // leave a clear alone
// A permanent forward is cached by browsers; refuse an insecure target.
if (($fwd['protocol'] ?? '') !== 'https' && (int) ($fwd['method'] ?? 0) === 301)
return 'A permanent forward wants a secure address.';
return null;
});Following an address forward
Runs after the forward was set at the provider or cleared from it.
Hook::add('action:domain.url_forwarding_changed', 10,
function ($service, $fwd, $verb) {
Audit::note('forwarding', $service['name'] ?? '',
$verb === 'cancel' ? 'cleared' : ($fwd['target'] ?? ''));
});Changing the forward that was read
Runs after the forward read from the provider was normalised, before it reaches the screen.
active, protocol, method, domain (target without a scheme), url (the full address). active decides the screen's "is there a forward" state and url pre-fills the target box.Hook::add('filter:domain.url_forwarding', 10, function (&$forwarding, $service) {
// Keep active and url in step: never one filled with the other empty.
if (empty($forwarding['url'])) $forwarding['active'] = false;
});Stopping a mail forward
Runs before a mail forwarding rule is created at the provider or deleted from it.
info means [email protected].create or delete.Hook::add('gate:domain.email_forward_save', 10,
function ($service, $prefix, $target, $verb) {
if ($verb === 'delete') return null;
// Forwarding the control boxes outward opens the door to a takeover.
if (in_array(strtolower($prefix), ['admin', 'postmaster', 'hostmaster'], true))
return 'That local part cannot be forwarded.';
return null;
});Following a mail forward
Runs after the rule landed at the provider.
Hook::add('action:domain.email_forward_saved', 10,
function ($service, $prefix, $target, $verb) {
MailAudit::rule($verb, $prefix . '@' . ($service['name'] ?? ''), $target);
});Changing the forwarding list
Runs after the rules read from the provider were normalised.
identity, prefix, source (prefix@domain) and target. A delete sends those three back, so do not drop them.Hook::add('filter:domain.email_forwards', 10, function (&$forwards, $service) {
// KEEP identity, prefix and target: a delete targets the rule with them.
usort($forwards, fn ($a, $b) => strcmp($a['prefix'] ?? '', $b['prefix'] ?? ''));
});Stopping an add-on purchase
Runs before the customer buys the add-on, with no record created yet.
dns-manage, whois-privacy or forwarding. The key uses hyphens; an underscore matches nothing.Hook::add('gate:domain.addon_purchase', 10, function ($service, $addonKey) {
// Selling a yearly add-on on a name about to expire is not right.
if (Acme::daysLeft($service) < 30)
return 'The domain is about to expire; renew it first.';
return null;
});Following an add-on order
Runs after the order was placed. The add-on is waiting here: it goes live once its invoice is paid.
invoice_id (the unpaid invoice raised), addon_id (the waiting record), addon_name.Hook::add('action:domain.addon_ordered', 10,
function ($service, $addonKey, $context) {
// The add-on is NOT live yet: it opens once the invoice is paid.
Crm::pendingAddon((int) ($context['invoice_id'] ?? 0), $addonKey);
});Pitfalls
While an address forward is being cleared, the hook hands you an empty array as the second parameter. A listener reaching for the old target finds nothing and quietly does the wrong thing. Where you need to know what was cleared, read it at the gate and keep it.
The source parameter is the local part alone, not a full address. You add the domain yourself, taking it from the service record. A listener expecting a full address matches nothing.
When an add-on order is placed the record is waiting and its invoice unpaid. Opening the surface here means handing over a service nobody paid for. The real opening happens on the payment side hooks.
The keys are dns-manage, whois-privacy and forwarding. Writing one with an underscore matches silently nothing: your listener runs and your condition never holds.
Related Articles
- Domain DNS Hooks
- Invoice and Payment Hooks
- Domain Hooks
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.