> ## Documentation Index
> Fetch the complete documentation index at: https://docs.commentify.pro/llms.txt
> Use this file to discover all available pages before exploring further.

# Spam pipeline

> Heuristic, Akismet, and custom checkers run on every Livewire and API save. Deny aborts; review holds for Filament.

Every comment — posted through Livewire **or** the API — passes through the spam pipeline in `CommentObserver` on **create** and on **body edit**.

| Verdict    | What happens                                                                                                                                                |
| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **deny**   | Save is aborted (`creating`/`updating` returns `false`). API returns 422 with `code: spam_rejected`. Webhook `spam_deny`. Logged to `commentify_spam_logs`. |
| **review** | Saved with `is_approved = false`. Hidden from the public thread. Webhook `pending`. Author can still see their own row.                                     |
| **allow**  | Normal flow (`is_approved` still respects `require_approval`).                                                                                              |

Checkers run in `spam.checkers` order. The **worst** verdict wins (deny > review > allow). A deny short-circuits remaining checkers. Reasons merge.

When an external checker throws or times out, `spam.fail_mode` is `allow` (fail open) or `review` (hold). Failures are `report()`ed.

## Built-in checkers

### `heuristic` (default, no network)

Configured under `spam.heuristic`:

| Signal                                                                                    | Default       | Verdict                                 |
| ----------------------------------------------------------------------------------------- | ------------- | --------------------------------------- |
| Blocked term in body (case-insensitive substring)                                         | `[]`          | **deny** `blocked_term`                 |
| Link host equals or is a subdomain of `blocked_domains`                                   | `[]`          | **deny** `blocked_domain:…`             |
| More than `max_links * 2` links                                                           | `max_links` 2 | **deny** `too_many_links`               |
| More than `max_links` links                                                               | 2             | **review** `too_many_links`             |
| Author has fewer than `trusted_after` approved comments **and** posted a link             | 3             | **review** `untrusted_author_with_link` |
| Identical body from the same author (or guest email/IP) within `duplicate_window` seconds | 300           | **review** `duplicate_body`             |

With `guests.stricter_spam` (default true):

* Guest missing email while `guest.require_email` → **deny** `guest_missing_email`
* Guest with any link → **review** `guest_with_link`
* Duplicate window also matches guest email + IP

### `akismet`

Set `AKISMET_KEY`. Posts to `{key}.rest.akismet.com/1.1/comment-check` with IP, user agent, author, email, body, and `comment_type` `reply` or `comment`.

* Akismet `true` + header `X-akismet-pro-tip: discard` → **deny** `akismet_discard`
* Akismet `true` otherwise → **review** `akismet_spam`
* Anything else, or missing key → **allow** (skips)

The installer can write the key to `.env`. Blank key at runtime is a no-op.

### `toxicity`

Google Perspective Comment Analyzer. See [AI toxicity](/toxicity). Add `'toxicity'` to `spam.checkers` and set `COMMENTIFY_PERSPECTIVE_KEY`. Billed per call.

## Logging

Non-allow verdicts persist to `commentify_spam_logs` when `spam.log` is true: `user_id`, `ip`, `body_hash` (SHA-256), 250-char `excerpt`, `decision`, `reasons`, `checker`. The pipeline runs **before** the row exists, so logs store a hash rather than a comment id.

## "Not spam" in Filament

On the **Spam Log** resource, **Not spam** re-approves the matching held comment (hash + author) and submits ham to Akismet when configured. Unmatched logs still write an audit row (`not_spam_unmatched`). See [Filament](/filament).

## Custom checkers

Implement `Usamamuneerchaudhary\CommentifyPro\Contracts\SpamChecker` and list the class name in `spam.checkers` (built-in aliases `heuristic`, `akismet`, `toxicity` are resolved first; anything else is treated as a FQCN):

```php theme={null}
use Usamamuneerchaudhary\CommentifyPro\Contracts\SpamChecker;
use Usamamuneerchaudhary\CommentifyPro\Spam\CommentCandidate;
use Usamamuneerchaudhary\CommentifyPro\Spam\SpamVerdict;

final class BlockUrlShorteners implements SpamChecker
{
    public function check(CommentCandidate $candidate): SpamVerdict
    {
        foreach ($candidate->links() as $link) {
            $host = parse_url($link, PHP_URL_HOST);

            if (in_array($host, ['bit.ly', 't.co'], true)) {
                return SpamVerdict::deny('url_shortener', 'block-url-shorteners');
            }
        }

        return SpamVerdict::allow();
    }
}
```

```php theme={null}
'spam' => [
    'checkers' => ['heuristic', \App\Spam\BlockUrlShorteners::class],
],
```

`CommentCandidate` includes `body`, `author`, `ip`, `userAgent`, `parentId`, `guestName`, `guestEmail`, plus `links()`, `isGuest()`, and `authorFrom()`.

<Tip>
  Pair guests with `require_approval` and heuristic. Humans should not be the first line of defence at 2am.
</Tip>
