Every good custom module starts with a real problem. This one started with an existing solution that almost fit — the Node Title Validation module (version 3.0.0), a well-maintained contributed module on Drupal.org that gives site builders fine-grained control over what authors can type into a node's title field. Its feature set was exactly right: minimum and maximum character limits, word counts, a blocklist for unwanted characters and words, and a uniqueness constraint. The only problem? Every line of it was hardwired to the title field.
The decision to clone rather than patch was deliberate. A clean, purpose-built module — Node Body Validation — owns its own config namespace, schema, admin route, and constraint plugin. No entanglement with the original module's development cycle.
Why the Body Field Needed Its Own Guardian
Content moderation in Drupal is typically a human task — editors approve or reject nodes before publication. But that process can't scale when a site receives high volumes of user-submitted content or is actively targeted by spam campaigns. The body field is the primary attack surface. Unlike the title — which a spammer must make look legitimate — the body can contain anything: hidden Arabic chatroom invitations, pharmaceutical advertisements padded with plausible-looking text, foreign-language content that gets past English-only editorial teams, and URLs embedded in BBCode tags like [url=https://...] that some text formats render as clickable links.
URL Injection
BBCode [url] tags, raw http:// links and www. prefixes used to embed spam links.
Pharma Spam
Generic drug names, dosage strings like 100mg, and pharmacy vocabulary for SEO poisoning.
Gambling Spam
Casino names, betting terms and operator brands inserted into otherwise legitimate-looking content.
Script Infiltration
Arabic, Cyrillic, Japanese, Korean and Vietnamese characters invisible to English-only editorial teams.
Crypto Spam
Cryptocurrency brands, DeFi jargon and investment-scam vocabulary like guaranteed profit.
SEO Manipulation
Link-building terms (buy backlinks, guest post) converting nodes into paid placement vehicles.
Beyond spam, the module also serves as a content quality gate: a minimum character count prevents placeholder saves, a maximum enforces editorial style guides, word count limits keep content neither too thin for SEO nor bloated beyond acceptable length, and uniqueness checks prevent duplicate content that hurts search rankings.
Architecture: How It Actually Works
The module is built on Drupal's Typed Data Constraint API — the same validation layer used by Drupal core itself. Validation fires at the entity level during $node->validate(), not only on form submission. REST API calls, migration imports, Feeds integrations — all are subject to the same rules.
Why hook_entity_bundle_field_info_alter and not hook_entity_base_field_info_alter?
The title field is a base field — it exists on every node, defined at the entity type level. The body field is a bundle field — configured per content type and altered via the bundle hook. Using the wrong hook means the constraint either never fires, or fires on types without a body field, causing PHP notices and unpredictable behavior.
The HTML stripping decision
When an author types into CKEditor, the saved value is HTML: <p>Hello <strong>world</strong></p>. The module takes a deliberate two-track approach:
- Character and word count checks use the stripped plain-text — that is what readers and search crawlers see.
- Blocked word checks also use stripped text — preventing authors hiding banned words inside HTML attributes.
- Uniqueness checks use the raw HTML — matching exact stored values, since identical visible text with different markup represents genuinely different documents.
Feature Walkthrough
Per-content-type configuration
Every content type with a body field gets its own collapsible panel on the admin form. Rules for Article do not affect Basic Page. The admin form only shows types that actually have a body field — types without one are silently excluded.
Character and word count limits
Both minimum and maximum character counts are measured after HTML stripping. The form enforces #min: 1 and the config schema enforces Range: min: 1 consistently — a value of 0 in the schema would silently disable the check, a schema bug caught and fixed during review. Word counts are computed after stripping HTML and collapsing whitespace; multiple consecutive spaces are normalized to a single space before splitting, preventing inflated counts.
Single-character entries are matched anywhere in the text via str_contains(). Multi-character entries are matched as whole words only, so blocking casino will not trigger on casinos or casinobet. For partial-stem blocking, list all variants explicitly, or use a regex-based approach in the validator.
Uniqueness enforcement
Two levels: per content type (no two articles share a body) and global (no two nodes anywhere on the site). Both correctly handle the re-save case — editing a node and saving it unchanged must not trigger a violation against itself, handled by comparing the current node's ID against query results before raising an error.
The Blocklist: 1,197 Entries Across 14 Categories
The module ships with an empty blocklist. A reference list was developed alongside the module through iterative research, covering the full range of spam patterns encountered in user-submission contexts.
| Category | Count | Sample entries |
|---|---|---|
| Arabic alphabet (U+0600–U+06FF) | 150 | ا ب ت ث ج ح خ… |
| Japanese Hiragana | 86 | あいうえおかきく… |
| Japanese Katakana | 86 | アイウエオカキク… |
| Cyrillic (Russian / Ukrainian / Bulgarian) | 248 | А Б В Г Д Е Ж З… |
| Korean (Hangul Jamo + common syllables) | 186 | ㄱㄴㄷ… 이가을… |
| Vietnamese unique diacritic chars | 68 | ắặẩẫậếềệ… |
| Portuguese / Spanish / French accented chars | 74 | ã â ç é ê ñ œ û… |
| URL / BBCode markup patterns | 6 | [url=, http://, www. |
| English profanity | ~90 | Use your imagination… |
| Adult / escort spam | ~25 | Use your imagination… |
| Pharmaceutical spam | ~50 | Use your imagination… |
| Gambling spam | ~25 | Use your imagination… |
| Cryptocurrency spam | ~20 | bitcoin, airdrop, ponzi… |
| SEO / link spam | ~15 | backlinks, guest post… |
The original blocklist made a critical mistake: it blocked individual Cyrillic letters like б, д, ж and individual Arabic letters like ا, ب, ت. This blocks entire languages, not spam. The right approach is to either block an entire script intentionally — or not at all.
Installation & Configuration
Place the module in web/modules/custom/node_body_validation and enable it:
drush en node_body_validation
drush crNavigate to the settings page at /admin/config/content/node-body-validation and grant access to your administrator role:
drush role:perm:add administrator "node body validation admin control"The settings page lists only content types that have a body field. For each you can configure: a comma-separated blocklist, a checkbox to also block the comma character, minimum and maximum character counts, minimum and maximum word counts, and a per-bundle uniqueness toggle. A global uniqueness checkbox at the bottom applies site-wide.
What Comes Next
- Substring matching: multi-character entries currently match whole words only. Optional regex or stem support would make patterns like
pharma*possible. - Unicode block range checking: checking whether a character's codepoint falls within a Unicode script range (e.g., U+0600–U+06FF for Arabic) is far more reliable than a per-character blocklist for full-script blocking.
- Functional test coverage: a test suite parallel to Node Title Validation's, covering each validation path, uniqueness checks, form submission, and config schema.
- Generalised field validation: the architecture can attach the constraint to any configured bundle text field, not just body — a natural next step toward a reusable "field content validation" module.