tearoom1 / uniform-spam-words
Simple spam guard for 'mzur/kirby-uniform' & Kirby
Package info
github.com/tearoom1/uniform-spam-words
Type:kirby-plugin
pkg:composer/tearoom1/uniform-spam-words
Requires
- php: >=8.1
- getkirby/composer-installer: ^1.1
- mzur/kirby-uniform: ^5.0
Requires (Dev)
- getkirby/cms: ^4.1.0 || ^5.0
- phpunit/phpunit: ^11.4.3
Suggests
None
Provides
None
Conflicts
- getkirby/cms: <4.1
Replaces
None
README
Spam protection for Martin Zurowietz' Kirby Uniform. Weighted word lists and link counting decide the clear cases on your own server. For messages close to the threshold, an optional AI check asks a decision model whether it is spam.
Getting started
Use one of the following methods to install & use tearoom1/uniform-spam-words:
Git submodule
If you know your way around Git, you can download this plugin as a submodule:
git submodule add https://github.com/tearoom1/uniform-spam-words.git site/plugins/uniform-spam-words
Composer
composer require tearoom1/uniform-spam-words
Clone or download
- Clone or download this repository from github: https://github.com/tearoom1/uniform-spam-words.git
- Unzip / Move the folder to
site/plugins.
Usage
Controller
To use the plugin, you have to enable the guard by calling spamWordsGuard() on the $form object.
For more information, check out the kirby-uniform docs on its magic methods:
$form = new Form(); if ($kirby->request()->is('POST')) { # Call security $form->spamWordsGuard(); # .. more code }
How it works
- Checks message length constraints
- Checks word count constraints
- Validates regex pattern (if configured)
- Runs custom validator (if configured)
- If addresses found (>= minAddresses), calculates spam score from keywords
- Optional: asks an AI decision model about close calls (see AI Check)
- Rejects if thresholds exceeded
Configuration
The plugin comes with a few lists of words and phrases that are used to build a spam score. Thus the plugin should work out of the box.
You may change certain options in your global config.php
Configuration Options
| Option | Type | Default | Description |
|---|---|---|---|
| Validation Options | |||
minLength |
int|null | null |
Minimum character length required for the message. Set to null to disable. |
maxLength |
int|null | null |
Maximum character length allowed for the message. Set to null to disable. |
minWords |
int|null | null |
Minimum word count required for the message. Set to null to disable. |
maxWords |
int|null | null |
Maximum word count allowed for the message. Set to null to disable. |
regexMatch |
string|null | null |
Optional regex pattern that the message must match (e.g., /^[a-zA-Z0-9\s]+$/). Set to null to disable. |
customValidator |
callable|null | null |
Custom validation callback that receives the message and returns true (valid) or false (invalid). Set to null to disable. |
| Spam Detection Options | |||
minAddresses |
int | 1 |
Minimum number of addresses required to trigger spam word checking. Set to 0 to always check. |
addressThreshold |
int | 2 |
Number of addresses (links/emails) allowed before triggering spam check. |
addressesWeight |
int | 1 |
Weight multiplier for each address (link/email) found. Each address contributes addressesWeight points to the total spam score. |
spamThreshold |
int | 8 |
Spam score threshold for rejection. Higher values are more lenient. |
useWordLists |
bool | true |
Use built-in spam word lists. Set to false to disable built-in lists. |
wordListPaths |
string|array|null | null |
Custom paths to word list files or directories. Always additive (added to built-in lists if enabled). Can be a single path (string) or multiple paths (array). |
spamWords |
array | [] |
Custom spam words with weights. Format: [weight => ['word1', 'word2']]. Higher weight = stronger spam signal. |
| Other Options | |||
enabled |
bool | true |
Enable or disable the plugin globally. |
fields |
array | ['message'] |
Form fields to check for spam. Specify multiple fields to combine them for spam checking (e.g., ['message', 'subject', 'body']). |
wordListCache |
bool | true |
Enable caching of word lists. Set to false to reload word lists on every request (useful for development). |
silentReject |
bool | false |
Reject spam without showing error messages (returns a space character). |
debug |
bool | false |
Enable debug logging. Logs ALL validation attempts with reason, metrics, form data, checked fields, anonymized IP, and timestamp. |
debugLogFile |
string|null | null |
Path to debug log file. Defaults to uniform-spam-words.log in the kirby logs directory (e.g. site/logs/uniform-spam-words.log). |
attachDebugInfo |
bool | false |
Attach spam check data to Form object (accessible via $form->data('spam_words_guard_info')) for use in email templates. Be aware that by default all fields are sent in uniform emails. |
| AI Check Options | See AI Check. | ||
ai.enabled |
bool | false |
Ask an AI decision model about close calls. |
ai.apiKey |
string|null | null |
OpenRouter API key. Without it the AI check is skipped. |
ai.model |
string | 'typesafe/jev-1.14' |
Any OpenRouter decision model. |
ai.endpoint |
string | 'https://openrouter.ai/api/alpha/decisions' |
Decisions API endpoint. |
ai.mode |
string | 'borderline' |
'borderline': only messages whose score is within ai.margin of spamThreshold. 'always': every message, also those without links or emails. |
ai.margin |
int | 3 |
Distance from spamThreshold that counts as a close call. |
ai.threshold |
float | 0.8 |
Spam probability (0–1) from which a message is rejected. |
ai.timeout |
int | 5 |
Seconds to wait before falling back to the word score. |
ai.context |
string|null | null |
Short description of the form, sent with the message, e.g. 'Contact form of a yoga studio in Munich'. |
Message Rejection & Spam Score
- A message is rejected if the spam score is higher than the threshold. Or if the number of addresses are higher than twice the
addressThreshold. - A messages is soft rejected (different message) if the number of addresses are higher than
addressThresholdbut the spam score is lower than the threshold. - The spam score is calculated by adding the weights of all matched words.
Plus the number of addresses found times the
addressesWeight.
Common Use Cases
Full Configuration Example
return [ 'tearoom1.uniform-spam-words' => [ 'enabled' => true, // enable the plugin, default true 'minLength' => 10, // the minimum length of the message, default null (disabled) 'maxLength' => 500, // the maximum length of the message, default null (disabled) 'minWords' => 3, // the minimum number of words in the message, default null (disabled) 'maxWords' => null, // the maximum number of words in the message, default null (disabled) 'regexMatch' => null, // the regex pattern to match against the message, default null (disabled) 'customValidator' => null, // custom validation callback, default null (disabled) 'minAddresses' => 0, // the minimum number of addresses like links and emails that are needed to check for spam, default 1 'addressThreshold' => 2, // the number of addresses like links and emails that are allowed, default 2 'addressesWeight' => 2, // the weight multiplier for each address (link/email), default 1 'spamThreshold' => 10, // the threshold for the spam score, default 8 'useWordLists' => true, // Use the default word lists, default true 'spamWords' => [ // define your own spam words, the key number defines the weight of the words 1 => ['promotion', 'free'], // weight 1, increases spam likelihood only a little 6 => ['seo', 'marketing'], // weight 6, increases spam likelihood a lot // Note: If a word exists in both built-in/file lists and spamWords config, // the config weight will override the file weight (no double counting) ], 'silentReject' => false, // Reject spam without showing error messages (returns a space as error message), default false 'debug' => true, // Enable debug logging, default false 'debugLogFile' => 'site/logs/my_custom_logfile.log', // Path to debug log file, default null (uses Kirby's log directory) ], ];
Contact Form with Basic Spam Protection
return [ 'tearoom1.uniform-spam-words' => [ 'minLength' => 10, 'minWords' => 3, 'addressThreshold' => 2, 'spamThreshold' => 8, ], ];
Strict Comment System
return [ 'tearoom1.uniform-spam-words' => [ 'minLength' => 20, 'maxLength' => 1000, 'minWords' => 5, 'maxWords' => 200, 'addressThreshold' => 1, // Only allow 1 link 'spamThreshold' => 5, // Stricter threshold ], ];
Multi-Field Spam Checking
return [ 'tearoom1.uniform-spam-words' => [ 'fields' => ['subject', 'message', 'company'], // Check multiple fields 'spamThreshold' => 10, ], ];
Increase Weight for Links/Emails
return [ 'tearoom1.uniform-spam-words' => [ 'addressesWeight' => 3, // Each link/email counts as 3 points instead of 1 'spamThreshold' => 12, // Adjust threshold accordingly ], ];
Development Mode (No Caching)
return [ 'tearoom1.uniform-spam-words' => [ 'wordListCache' => false, // Disable caching for testing 'debug' => true, // Enable detailed logging 'debugLogFile' => '/path/to/spam-debug.log', ], ];
Custom Validation with Industry-Specific Terms
return [ 'tearoom1.uniform-spam-words' => [ 'spamWords' => [ 3 => ['crypto', 'nft', 'investment'], 5 => ['guaranteed', 'risk-free'], 8 => ['get rich quick'], ], 'customValidator' => function($message) { // Reject if message contains phone numbers return !preg_match('/\d{3}[-.\s]?\d{3}[-.\s]?\d{4}/', $message); }, ], ];
Debug Mode for Tuning
return [ 'tearoom1.uniform-spam-words' => [ 'debug' => true, 'debugLogFile' => '/path/to/spam-debug.log', ], ];
Debug logging captures:
- ✅ All rejections (regex, length, words, custom validator, spam detection)
- ✅ Successful validations (messages that pass all checks)
- ✅ Rejection reason and relevant metrics for each attempt
- ✅ Form data - All submitted form fields (truncated to 100 chars, excludes passwords/tokens)
- ✅ Checked fields - Which fields were combined for spam checking
- ✅ Anonymized IP address (GDPR compliant) and timestamp for tracking
Example log entry:
{
"status": "rejected",
"reason": "spam_score",
"checked_fields": ["message"],
"message_length": 150,
"word_count": 25,
"address_count": 3,
"addresses_weight": 1,
"address_score": 3,
"spam_score": 12,
"total_score": 15,
"matched_words": {
"seo": {
"weight": 6,
"count": 2,
"subtotal": 12
}
},
"thresholds": {
"spam": 8,
"address": 2
},
"form_data": {
"name": "John Doe",
"email": "spam@example.com",
"message": "Buy now! Amazing SEO services..."
}
}
IP Anonymization (GDPR Compliant):
- IPv4: Last octet masked (e.g.,
192.168.1.123→192.168.1.0) - IPv6: Last 4 segments masked (e.g.,
2001:db8:85a3:8d3:1319:8a2e:370:7348→2001:db8:85a3:8d3:0000:0000:0000:0000)
AI Check (optional)
Word lists are fast and free, but they misjudge messages close to the threshold. The AI check asks a decision model on OpenRouter whether such a message is spam. By default it uses Jev, which answers with a probability instead of text.
return [ 'tearoom1.uniform-spam-words' => [ 'ai.enabled' => true, 'ai.apiKey' => 'sk-or-v1-YOUR-KEY', 'ai.context' => 'Contact form of a yoga studio in Munich', ], ];
How it decides
- In the default
borderlinemode, only messages whose total score is withinai.marginofspamThresholdare sent. Clear cases are decided by the word score as before, so most messages never leave your server. - If the model says the spam probability is at least
ai.threshold, the message is rejected; otherwise it passes. This works both ways: it catches spam just below the threshold and lets genuine messages just above it through. - More than twice
addressThresholdlinks or emails are always rejected, without asking. alwaysmode sends every message, also those without links or emails, which the word score skips.- If the request fails, times out or no key is set, the word score decides as if the AI check were off. A failed request never rejects a message.
Cost and speed: about 0.00002 USD and 0.3–0.5 seconds per checked message with Jev (measured October 2026; current prices on the model page). Other decision models work through the same API: set ai.model, then check ai.threshold again, because probabilities differ between models.
Privacy: only the checked fields and ai.context are sent, never other form fields such as name or email. The message itself can still contain personal data, and it is processed by OpenRouter and the model provider. Mention this in your privacy policy before you enable the check. With debug on, the log shows the probability, model and cost of each request.
Custom Word Lists
You can use your own spam word list files instead of or in addition to the built-in lists.
File Format
Word list files should be plain text files (.txt) with:
- One term (single word or combination) per line
- Weight encoded in filename as
_n.txtwherenis the weight (1-9) - Example:
custom_spam_5.txt(weight 5)
Example file content (my_spam_words_7.txt):
casino
lottery
winner
prize
Configuration Options
Single file:
'tearoom1.uniform-spam-words' => [ 'wordListPaths' => '/path/to/my_spam_words_7.txt', ],
Multiple files:
'tearoom1.uniform-spam-words' => [ 'wordListPaths' => [ '/path/to/my_spam_words_5.txt', '/path/to/another_list_8.txt', ], ],
Directory (loads all .txt files):
'tearoom1.uniform-spam-words' => [ 'wordListPaths' => '/path/to/my-spam-lists', ],
Multiple directories:
'tearoom1.uniform-spam-words' => [ 'wordListPaths' => [ '/path/to/spam-lists', '/path/to/more-lists', ], ],
Combining with Built-in Lists
Custom word lists are added to the built-in lists (if enabled). The options work independently:
Built-in + Custom (default):
'tearoom1.uniform-spam-words' => [ 'useWordLists' => true, // Use built-in lists (default) 'wordListPaths' => '/path/to/my-lists', // Add custom lists ],
Notes
- Custom word lists are cached for 24 hours (same as built-in lists)
- Weight determines spam score contribution (higher = more spam-like)
- Words are case-insensitive
- Empty lines are skipped
- Files must be readable by the web server
Supported Languages
The plugin includes built-in translations for error messages in the following languages:
- English (en) - Default language
- German (de) - Deutsche Übersetzungen
- French (fr) - Traductions françaises
The plugin automatically uses the appropriate language based on your Kirby site's language configuration.
Custom Error Messages
Single-language sites: Define custom messages in config.php:
'tearoom1.uniform-spam-words' => [ 'msg' => [ 'rejected' => 'Your custom spam rejection message', 'soft-reject' => 'Your custom soft rejection message', 'regex-mismatch' => 'Message does not match the required pattern.', 'too-short' => 'Message is too short.', 'too-long' => 'Message is too long.', 'too-few-words' => 'Message contains too few words.', 'too-many-words' => 'Message contains too many words.', 'custom-validation-failed' => 'Message failed custom validation.', ], ],
Multi-language sites: Define translations in your language files (site/languages/*.php):
return [ 'code' => 'en', 'name' => 'English', 'translations' => [ 'tearoom1.uniform-spam-words.msg.rejected' => 'Your custom spam rejection message', 'tearoom1.uniform-spam-words.msg.soft-reject' => 'Your custom soft rejection message', 'tearoom1.uniform-spam-words.msg.regex-mismatch' => 'Message does not match the required pattern.', 'tearoom1.uniform-spam-words.msg.too-short' => 'Message is too short.', 'tearoom1.uniform-spam-words.msg.too-long' => 'Message is too long.', 'tearoom1.uniform-spam-words.msg.too-few-words' => 'Message contains too few words.', 'tearoom1.uniform-spam-words.msg.too-many-words' => 'Message contains too many words.', 'tearoom1.uniform-spam-words.msg.custom-validation-failed' => 'Message failed custom validation.', ], ];
Support
Uniform Spam Words is free and MIT licensed, and it stays that way. If it saves you time, you can help keep it maintained. Paying is voluntary and any amount is welcome.
Agencies and companies that need an invoice can get a Supporter License. You choose the price and whether it covers one project or your whole company. Lemon Squeezy handles the sale as merchant of record, so you get a proper invoice with VAT. The license adds no features and no priority support.
License
This plugin is licensed under the MIT License
Credits
- Developed by Mathis Koblin
Screenshots
Screenshots use sample content at 1408 × 902 pixels. Panel views are cropped to the content area. The main image uses the light theme.
| View | Light | Dark |
|---|---|---|
| Overview | Light | Dark |
| AI check card (1680 × 840) | Light |
The spam rejection previews use the contact form with neutral T-Room styling.
