# Domain Catalogue Hooks

https://dev.wisecp.com/es/domain-catalogue-hooks

The ten hooks running while a customer searches for a name and an operator builds the extension catalogue: availability, suggestions, categories, the price matrix.

## Overview

Selling domains has two sides. On the customer's side there is **the search**: a name is typed, the provider is asked, a result and suggestions come back. On the operator's side there is **the catalogue**: which extensions sell, in which category, at what price.

Every hook here works **by reference**: you are handed an array, you change it, and your return is not read. The shape contract is tight, because the screen and the cart read the output directly.

## Reference

### Changing the availability answer

filterdomain.availability

`AdminOrders::check_domain():336` a panel order

Runs after the provider's availability answer was worked out, before the response is built.

Parameters 4

$availableboolrefThe availability the provider reported. Setting it to `false` takes the name off sale.

$sldstringThe name itself, without the extension.

$tldstringThe extension.

$check_resultarrayThe provider's raw answer. Read the detail here to justify your decision.

Return 1

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

Listener PHP

```php
Hook::add('filter:domain.availability', 10,
    function (&$available, $sld, $tld, $check_result) {
        // Brand protection: nobody registers our own name.
        if (Acme::brandTerm($sld)) $available = false;
    });
```

### Changing the customer search result

filterdomain.client_search_results

`ClientDomain::check():124` already sorted

Runs after the primary result and the suggestions were built, the cart state was applied **and the suggestions were sorted**.

Parameters 2

$responsearrayrefThe whole answer: `status`, `type` (`register` or `transfer`), `currency`, `primary` (the primary result or `null`), `suggestions`. Cart state arrives on each entry as `in_cart`.

$searchContextarrayThe query context: `sld`, `tld`, `transfer`, `ucid` (the display currency).

Return 1

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

Listener PHP

```php
Hook::add('filter:domain.client_search_results', 10,
    function (&$response, $searchContext) {
        // Sorting already ran: put your suggestion FIRST, not last.
        $extra = Acme::suggest($searchContext['sld'] ?? '');
        if ($extra) array_unshift($response['suggestions'], $extra);
    });
```

### Changing the featured extensions

filterdomain.spotlight_tlds

`ClientDomain::spotlight_tlds():275` no dot, lower case

Runs after the list from the language file was lower-cased and any leading dot trimmed.

Parameters 1

$tldsarrayrefThe extension list: plain strings, **no dot and lower case** (`["net","org","io"]`). A dot or a capital makes the extension unfindable.

Return 1

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

Listener PHP

```php
Hook::add('filter:domain.spotlight_tlds', 10, function (&$tlds) {
    $tlds = ['com', 'net', 'co.uk'];         // no dot, lower case
});
```

### Changing the extension categories

filterdomain.tld_categories

`domain::build_categories():86` key and label

Runs after the category keys were matched with their translated labels.

Parameters 1

$outarrayrefThe ordered category list: every row carries `key` and `label`. The key has to match the one on the extension rows; an invented key returns no extensions.

Return 1

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

Listener PHP

```php
Hook::add('filter:domain.tld_categories', 10, function (&$out) {
    // The key HAS TO MATCH the category list on the extension rows.
    $out[] = ['key' => 'local', 'label' => 'Local'];
});
```

### Changing the extension price table

filterdomain.tld_table

`website/domain` prices resolved

Runs after the extension rows were built and the prices resolved into the display currency.

Parameters 2

$outarrayrefThe extension rows. Each row: `name` (**without a dot**), `categories` (comma-separated keys), the register and renew prices.

$ucidintThe display currency id. The prices are in this currency; give any row you add in the same one.

Return 1

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

Listener PHP

```php
Hook::add('filter:domain.tld_table', 10, function (&$out, $ucid) {
    // Hide rows without a price so nobody clicks a priceless extension.
    $out = array_values(array_filter($out,
        fn ($r) => (float) ($r['register'] ?? 0) > 0));
});
```

### Changing the renewal price

filterdomain.premium_renewal_price

`Invoices::_renewal_pricing_domain()` already converted

Runs after the extension's renewal price was found and the currency conversion and year multiplier applied.

Parameters 4

$renewal_amountfloatrefThe unit price in the target currency. The multiplier is applied already, and what you write here reaches the invoice.

$servicearrayThe domain service being renewed.

$tldarrayThe extension record: `id`, `name`, `min_years`.

$target_currencyintThe currency the amount is in.

Return 1

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

Listener PHP

```php
Hook::add('filter:domain.premium_renewal_price', 10,
    function (&$renewal_amount, $service, $tld, $target_currency) {
        // A premium name renews at the provider's price, not the catalogue's.
        $real = Acme::premiumRenewal($service['name'] ?? '', $target_currency);
        if ($real > 0) $renewal_amount = $real;
    });
```

### Changing the price matrix before it is saved

filterdomain.pricing_save

`AdminProducts` type by year by currency

Runs after the costs pulled from the provider were turned into prices with the profit rate, before they are written.

Parameters 2

$pricingarrayrefThe price matrix: `[type][year][currency]` → `cost` and `promo`. Types: `register`, `renewal`, `transfer`.

$contextarrayThe context: `module`, `tld`, `cost_cid`, `profit_rate`.

Return 1

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

Listener PHP

```php
Hook::add('filter:domain.pricing_save', 10, function (&$pricing, $context) {
    // Sell the first year at cost; leave renewals alone.
    foreach ($pricing['register'][1] ?? [] as $cid => $row)
        $pricing['register'][1][$cid]['promo'] = $row['cost'];
});
```

### Changing a new extension record

filterdomain.tld_save_data

`AdminProducts` on create only

Runs before a new extension is written to the database.

Parameters 2

$insert_dataarrayrefThe record about to be written: `name`, `module`, `status`, `rank`, `dns_manage`, `forwarding`, `whois_privacy`, `epp_code`.

$extensionstringThe extension being added.

Return 1

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

Listener PHP

```php
Hook::add('filter:domain.tld_save_data', 10, function (&$insert_data, $extension) {
    // A new extension arrives switched off, so nothing sells without a price.
    $insert_data['status'] = 0;
});
```

### Stopping an extension delete

gatedomain.tld_delete

`AdminProducts` takes it off the catalogue

Runs before an extension is deleted from the catalogue.

Parameters 2

$extensionstringThe extension about to be deleted.

$tldarrayThe extension record.

Return 1

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

Listener PHP

```php
Hook::add('gate:domain.tld_delete', 10, function ($extension, $tld) {
    // Deleting an extension with sold names leaves those records orphaned.
    if (Acme::soldCount((int) ($tld['id'] ?? 0)) > 0)
        return 'Names are sold on this extension; it cannot be deleted.';

    return null;
});
```

### Following a new extension

actiondomain.tld_created

`AdminProducts` the id is filled in

Runs after the extension was created in the catalogue.

Parameters 3

$tld_idintThe id of the created record.

$extensionstringThe extension name.

$registrarstringThe provider module assigned. Without a module chosen, an empty key arrives.

Return 1

voidThe return is ignored.

Listener PHP

```php
Hook::add('action:domain.tld_created', 10,
    function ($tld_id, $extension, $registrar) {
        Ops::note('tld-added', $extension . ' -> ' . ($registrar ?: 'no module'));
    });
```

### Taking over a single lookup

filterdomain.available

`Registrar::check` takes over by returning

Runs before the availability question is asked for one extension. Return a filled answer and the system asks neither WHOIS nor the registrar: you answered.

Parameters 1

$queryarrayWhat is being asked: `sld`, `tld`, `module`. An empty `module` puts you on the WHOIS path, a filled one on the registrar path. Both pass through here.

Return 1

array|falseA filled array **takes over** the lookup: `status` (`available`, `unavailable` or `error`), with optional `message`, `premium`, `premium_price`. An empty array, `false` or no return lets the system carry on. With several listeners answering, the last one holds.

Listener PHP

```php
Hook::add('filter:domain.available', 10, function ($query) {
    // Never ask the registrar about names on your block list.
    if (Acme::blocked($query['sld'] ?? ''))
        return ['status' => 'unavailable', 'message' => 'this name is not offered'];

    return false;   // let the system ask for the rest
});
```

### Changing the collected result map

filterdomain.whois_result

`Registrar::check` passed by link

Runs once every extension has answered, right before the result goes back to the caller. You hold the whole map, not one extension.

Parameters 2

$resultarrayby linkDomain against result; each result carries `sld`, `tld`, `status`, and may carry `message`, `premium`, `premium_price`.

$contextarrayWhat was asked: `sld` and `tlds`.

Return 1

voidThe return is ignored; you write over the map. Overriding a premium price here is easier than catching each extension lookup.

Listener PHP

```php
Hook::add('filter:domain.whois_result', 10, function (&$result, $context) {
    foreach ($result as $domain => $row) {
        if (($row['premium'] ?? false) !== true) continue;

        // Add your own margin to the premium price.
        $result[$domain]['premium_price'] = round(((float) $row['premium_price']) * 1.15, 2);
    }
});
```

### Masking the raw record

filterdomain.whois_record

`ClientDomain::whois` passed by link personal data

Runs before the raw text from the registrar is shown to a visitor. That text holds somebody else's name, email and phone, so this is where masking belongs.

Parameters 2

$rawstringby linkThe full record text. It **arrives empty** when no record could be fetched, which is also where you can feed text from a source of your own.

$whoisContextarrayWhich name was asked for: `sld`, `tld`, `status`.

Return 1

voidThe return is ignored; you write over the text.

Listener PHP

```php
Hook::add('filter:domain.whois_record', 10, function (&$raw, $whoisContext) {
    if ($raw === '') return;

    // Hide the email and phone lines.
    $raw = preg_replace('~^(.*(?:Email|Phone).*)$~mi', '[hidden]', $raw);
});
```

### Following a document requirement change

actiondomain.doc_saved

`AdminProductsDomain` compliance record

Runs after the required document list of an extension changes. The change reaches backwards: orders already waiting on that extension may now need a different document.

Parameters 4

$tldstringThe extension, without a dot and in lower case.

$addedarrayNewly added document definitions.

$updatedarrayChanged definitions, id against data.

$removedarrayThe ids of definitions taken away.

Return 1

voidThe return is ignored. The record is already written.

Listener PHP

```php
Hook::add('action:domain.doc_saved', 10, function ($tld, $added, $updated, $removed) {
    // A new document means waiting orders on that extension deserve a second look.
    if ($added) Acme::reviewPendingOrders($tld);
});
```

## Pitfalls

> **The search filter runs AFTER sorting**
> 
> The suggestions are sorted by state before you see them. A suggestion appended to the list stays **at the bottom**, where most customers never look. To put yours forward, place it at the **front**.

> **The extension format is strict**
> 
> The featured list and the table rows want the extension **without a dot and in lower case**. Writing `.COM` breaks the match: the extension is found in no catalogue, no price resolves, and the screen quietly shows nothing.

> **Price hooks come after conversion**
> 
> Both the renewal price and the table rows hand you a **converted** amount. Write your own price in that same currency; dropping the provider's cost in as-is shows the customer **a number from another currency**.

> **A category key has to match the extension rows**
> 
> Adding a key to the category list opens **the button** and nothing else. That key also has to appear in the category list on the extension rows, or a customer pressing it sees **an empty list**.

## Related Articles

- [Domain Acquisition Hooks](https://dev.wisecp.com/en/domain-acquisition-hooks)
- Order Flow Hooks
- Domain Hooks
