# Site Content Hooks

https://dev.wisecp.com/es/client-content-hooks

The twelve hooks over pages, news, slides, testimonials and the menu.

## Overview

The written content of the site lives here: pages, contracts, news, articles, references, slides, testimonials and the menu.

The save path splits into two filters: the **main record** passes once, the **language data** once per language. If you clean the body, the second is the right place.

## Reference

### Stopping content being deleted

gateclient.content_delete

`ClientContent` five content types

Runs before a piece of site content is deleted. Pages, contracts, news, articles and references share this gate.

Parameters 2

$pagearrayThe record about to be deleted.

$page_typestringThe content type. Deleting a contract can affect the order flow tied to it: be stricter for some types.

Return 1

string|null**A non-empty text blocks the operation** and is shown as the error. An empty return lets it carry on.

Listener PHP

```php
Hook::add('gate:client.content_delete', 10, function ($page, $page_type) {
    // Deleting a contract can affect the order flow.
    if ($page_type === 'contract') return 'Contracts cannot be deleted; switch them off instead.';

    return null;
});
```

### Following content being deleted

actionclient.content.deleted

`ClientContent` after deletion

Runs after a piece of site content is deleted.

Parameters 3

$idintThe id of the deleted record.

$page_typestringThe content type.

$pagearrayThe record as read before deletion.

Return 1

voidThe return is ignored.

Listener PHP

```php
Hook::add('action:client.content.deleted', 10, function ($id, $page_type, $page) {
    Acme::dropFromIndex('page', $id);
});
```

### Following content being viewed

actionclient.content.viewed

`ClientContent` on every visit

Runs when a piece of site content is visited. The visitor may not be logged in.

Parameters 3

$idintThe id of the record viewed.

$typestringThe content type.

$recordarrayThe record data, with the visit counter already increased. Its shape follows the type: a blog record carries a category, a plain page does not.

Return 1

voidThe return is ignored.

Listener PHP

```php
Hook::add('action:client.content.viewed', 10, function ($id, $type, $record) {
    // It runs on every visit: keep it light.
    Acme::trackView($type, $id);
});
```

### Changing the content detail data

filterclient.content_detail.data

`ClientContent` passed by link

Runs before the content reaches the screen. The place to resolve your own shortcodes or adjust the search headings.

Parameters 3

$recordarrayby linkThe record data: title, body, link and search headings.

$idintby linkThe record id.

$typestringby linkThe content type.

Return 1

voidThe return is ignored; you write over the data.

Listener PHP

```php
Hook::add('filter:client.content_detail.data', 10, function (&$record, &$id, &$type) {
    if ($type !== 'articles') return;

    $record['content'] = Acme::resolveShortcodes($record['content'] ?? '');
});
```

### Changing the content data before it is saved

filterclient.content_save_data

`ClientContent` options are text

Runs before the main record of a piece of content is written.

Parameters 3

$set_dataarrayby linkThe data to be written. ? The options field is **text**, not an array: to change it you must decode, edit and encode again. Writing to it as an array corrupts the record.

$page_typestringThe content type.

$idintThe record id; **zero on a new record**.

Return 1

voidThe return is ignored; you write over the data.

Listener PHP

```php
Hook::add('filter:client.content_save_data', 10, function (&$set_data, $page_type, $id) {
    // The options are TEXT: decode, edit, encode again.
    $opt = Utility::jdecode($set_data['options'] ?? '', true) ?: [];
    $opt['acme_reviewed'] = 1;
    $set_data['options'] = Utility::jencode($opt);
});
```

### Changing the language data before it is saved

filterclient.content_lang_data

`ClientContent` per language

Runs separately for each language of the content. The body is raw markup, which makes this the right place to clean it.

Parameters 3

$set_lang_dataarrayby linkThe data of that language: title, address, body and search headings. Leave the address empty and it falls back to the record id.

$lkeystringThe language key.

$page_typestringThe content type.

Return 1

voidThe return is ignored; you write over the data. The hook runs once per language: on a two-language record you are called twice.

Listener PHP

```php
Hook::add('filter:client.content_lang_data', 10,
    function (&$set_lang_data, $lkey, $page_type) {
        // The body is raw markup: this is the right place to clean it.
        $set_lang_data['content'] = Acme::sanitize($set_lang_data['content'] ?? '');
    });
```

### Following a page being saved

actionclient.page_saved

`ClientContent` create and edit

Runs after a page is saved.

Parameters 3

$idintThe record id; on a new record the real id is passed.

$page_typestringThe page type.

$is_newboolTrue when newly created.

Return 1

voidThe return is ignored.

Listener PHP

```php
Hook::add('action:client.page_saved', 10, function ($id, $page_type, $is_new) {
    Acme::reindex('page', $id);
});
```

### Following a blog record being saved

actionclient.blog.saved

`ClientContent` create and edit

Runs after a blog record is saved.

Parameters 2

$idintThe record id; on a new record the real id is passed.

$is_newboolTrue when newly created.

Return 1

voidThe return is ignored.

Listener PHP

```php
Hook::add('action:client.blog.saved', 10, function ($id, $is_new) {
    Acme::reindex('page', $id);
});
```

### Following a news item being saved

actionclient.news.saved

`ClientContent` create and edit

Runs after a news item is saved.

Parameters 2

$idintThe record id; on a new record the real id is passed.

$is_newboolTrue when newly created.

Return 1

voidThe return is ignored.

Listener PHP

```php
Hook::add('action:client.news.saved', 10, function ($id, $is_new) {
    Acme::reindex('page', $id);
});
```

### Following a slide being saved

actionclient.slide.saved

`ClientContent` create and edit

Runs after a slide is saved.

Parameters 2

$idintThe record id; on a new record the real id is passed.

$is_newboolTrue when newly created.

Return 1

voidThe return is ignored.

Listener PHP

```php
Hook::add('action:client.slide.saved', 10, function ($id, $is_new) {
    Acme::reindex('page', $id);
});
```

### Following a testimonial being saved

actionclient.feedback.saved

`ClientContent` the approval state

Runs after a customer testimonial on the site is saved.

Parameters 3

$idintThe testimonial id.

$statusstringIts state: approved or awaiting approval. One awaiting approval **does not appear** on the site: check before doing anything tied to publication.

$is_newboolTrue when newly created.

Return 1

voidThe return is ignored.

Listener PHP

```php
Hook::add('action:client.feedback.saved', 10, function ($id, $status, $is_new) {
    // One awaiting approval does not appear on the site.
    if ($status === 'approved') Acme::publishTestimonial($id);
});
```

### Changing menu edits before they are saved

filterclient.menu_save_changes

`ClientContent` a batch of operations

Runs before changes to the site menu are applied.

Parameters 1

$changesarrayby linkThe changes to apply. It is not one record but a **batch**: each entry carries an operation type and its data. Adding, editing, deleting and reordering can sit together in one request.

Return 1

voidThe return is ignored; you write over the data.

Listener PHP

```php
Hook::add('filter:client.menu_save_changes', 10, function (&$changes) {
    // A batch: adding, editing, deleting and reordering can sit together.
    $changes = array_values(array_filter($changes,
        fn ($c) => ($c['type'] ?? '') !== 'delete' || Acme::menuDeletable($c['data'] ?? [])));
});
```

## Pitfalls

> **The options field is text, not an array**
> 
> In the content save filter the options field arrives as **encoded text**. Writing a key into it as though it were an array corrupts the field and leaves the record unreadable. The fix: decode, edit, encode again.

> **The language filter runs once per language**
> 
> The language data filter is called **per language**: three times on a three-language record. A listener keeping a counter or doing one-off work runs three times here. Use the main record filter for anything that should happen once.

## Related Articles

- [Customer Site Hooks](https://dev.wisecp.com/en/hooks-on-the-customer-site)
- [Knowledge Base Display Hooks](https://dev.wisecp.com/en/knowledge-base-display-hooks)
- [How Hooks Work](https://dev.wisecp.com/en/how-hooks-work)
