# Domain Forwarding Hooks

https://dev.wisecp.com/es/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

gatedomain.url_forwarding_change

`ClientDomains` set and clear

Runs before the forward is set at the provider or cleared from it.

Parameters 3

$servicearrayThe domain service record.

$fwdarrayThe forward: `protocol` (`http`/`https`), `target` (the target without a scheme, trailing slash trimmed), `method` (`301`/`302`). On a clear it arrives as an **empty array**.

$verbstring`save` or `cancel`.

Return 1

stringA non-empty string **stops** the action; the text is thrown as the error and the provider is never called.

Listener PHP

```php
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

actiondomain.url_forwarding_changed

`ClientDomains` on success only

Runs after the forward was set at the provider or cleared from it.

Parameters 3

$servicearrayThe domain service record.

$fwdarrayThe forward that was set. On a clear it is an empty array, so do not read a target from it.

$verbstringThe action that ran.

Return 1

voidThe return is ignored.

Listener PHP

```php
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

filterdomain.url_forwarding

`Hook::runRefs` by reference

Runs after the forward read from the provider was normalised, before it reaches the screen.

Parameters 2

$forwardingarrayrefA single record: `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.

$servicearrayThe domain service record.

Return 1

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

Listener PHP

```php
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

gatedomain.email_forward_save

`ClientDomains` create and delete

Runs before a mail forwarding rule is created at the provider or deleted from it.

Parameters 4

$servicearrayThe domain service record.

$prefixstringThe local part on the domain. Only the part arrives, not the full address: `info` means `info@example.com`.

$targetstringThe target box. It can arrive empty on a delete.

$verbstring`create` or `delete`.

Return 1

stringA non-empty string **stops** the action; the text is thrown as the error and the provider is never called.

Listener PHP

```php
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

actiondomain.email_forward_saved

`ClientDomains` on success only

Runs after the rule landed at the provider.

Parameters 4

$servicearrayThe domain service record.

$prefixstringThe source local part.

$targetstringThe target box.

$verbstringThe action that ran.

Return 1

voidThe return is ignored.

Listener PHP

```php
Hook::add('action:domain.email_forward_saved', 10,
    function ($service, $prefix, $target, $verb) {
        MailAudit::rule($verb, $prefix . '@' . ($service['name'] ?? ''), $target);
    });
```

### Changing the forwarding list

filterdomain.email_forwards

`Hook::runRefs` by reference

Runs after the rules read from the provider were normalised.

Parameters 2

$forwardsarrayrefThe rule list: every row carries `identity`, `prefix`, `source` (`prefix@domain`) and `target`. A delete sends those three back, so do not drop them.

$servicearrayThe domain service record.

Return 1

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

Listener PHP

```php
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

gatedomain.addon_purchase

`ClientDomains` before the invoice

Runs before the customer buys the add-on, with **no record created yet**.

Parameters 2

$servicearrayThe domain service record.

$addonKeystringThe add-on being bought: `dns-manage`, `whois-privacy` or `forwarding`. The key uses **hyphens**; an underscore matches nothing.

Return 1

stringA non-empty string **stops** the purchase; it reaches the customer as the error and **no record is created**.

Listener PHP

```php
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

actiondomain.addon_ordered

`ClientDomains` the invoice is unpaid

Runs after the order was placed. The add-on is **waiting** here: it goes live once its invoice is paid.

Parameters 3

$servicearrayThe domain service record.

$addonKeystringThe add-on that was ordered.

$contextarrayThe order context: `invoice_id` (the **unpaid** invoice raised), `addon_id` (the waiting record), `addon_name`.

Return 1

voidThe return is ignored.

Listener PHP

```php
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

> **On a clear the forward arrives empty**
> 
> 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 mail hook hands you the local part**
> 
> 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**.

> **The order hook is not the moment the add-on opens**
> 
> 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 add-on key uses hyphens**
> 
> 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](https://dev.wisecp.com/en/domain-dns-hooks)
- Invoice and Payment Hooks
- Domain Hooks
