overtrue/laravel-text-guard
A Laravel package for guarding text input against sensitive or banned words. It offers configurable filtering and validation to help detect and block unwanted content in forms, comments, and other user-generated text with minimal setup.
Install the package
composer require overtrue/laravel-text-guard:^1.0
Version 1.0.4 only relaxes the framework constraint to include Laravel 12/13 — there are no API, config, or dictionary-format changes versus 1.0.3. Treat it as a drop-in bump: pin ^1.0.4 if you develop against Laravel 13, otherwise ^1.0 remains fine and the upgrade carries no migration work.
Publish the config (the only setup step that usually matters):
php artisan vendor:publish --tag=text-guard-config
This drops config/text-guard.php in place. That file is the first place to look — it is where the word list (inline array and/or dictionary file path), replacement character, and any caching options live.
Where to look next, in order
config/text-guard.php — words, dictionary file path, replacement strategy.First useful case: guarding user-generated text
use Overtrue\LaravelTextGuard\Facades\TextGuard;
$guard = TextGuard::make();
$guard->check('some user submitted text'); // bool
$guard->filter('some user submitted text'); // masked string
Start with comments, reviews, nicknames, and chat messages — the classic places where a small sensitive-word list plus a mask is enough and a third-party moderation API is overkill.
Validation rule: reject rather than mask. For fields that must be clean (usernames, titles), fail the request instead of silently rewriting user input:
use Overtrue\LaravelTextGuard\Facades\TextGuard;
use Illuminate\Validation\ValidationException;
class Clean implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (TextGuard::make()->check((string) $value)) {
$fail("The {$attribute} field contains prohibited language.");
}
}
}
Display-time masking. For free-form content, store the original and mask on output so moderation decisions are reversible. Don't destroy the raw text at write time unless you have a separate audit path — you'll want it when you tune the dictionary.
Dictionary loading strategy. Keep the bulk of the words in a versioned file (or a dedicated banned_words table) and only the path/options in config/text-guard.php. This lets you update the dictionary without touching config cache state and lets non-developers contribute words via a deploy.
Bind once, reuse. The package's service provider typically registers a singleton; resolve through the facade or container rather than new Guard(...) inside loops, otherwise you rebuild the trie on every message. For batch jobs (importing old comments, scanning a table), resolve once outside the loop:
$guard = app(TextGuard::class);
UserComment::chunkById(500, function ($comments) use ($guard) {
foreach ($comments as $comment) {
if ($guard->check($comment->body)) {
$comment->update(['needs_review' => true]);
}
}
});
Soft-flag before hard-block. A mature integration pattern: on first match, set a needs_review flag and let content through; escalate to hard rejection only for high-severity words. This keeps false positives from breaking checkout flows or support tickets.
Pair with a Laravel event. Fire a TextGuardMatched event on match so you can log, alert, or feed the offending content into a moderation queue, keeping the guard itself stateless.
Test with a fixture dictionary. In tests, override the config to a tiny inline list so assertions are deterministic and don't drift every time someone adds a word:
config(['text-guard.words' => ['badword']]);
Config caching silently freezes your word list. php artisan config:cache snapshots the inline words array; adding words to the config file without re-caching does nothing. Move the list to a dictionary file read at runtime, or add a deploy step that re-caches.
Obfuscation defeats naive matching. Spaces, punctuation, and zero-width characters between characters are the standard bypass. Normalize input before checking — strip whitespace and punctuation, fold full-width to half-width, lowercase — otherwise your dictionary will look fine in tests and fail in production.
English substrings cause the Scunthorpe problem. Substring matching is correct for CJK but wrong for Latin words. Keep short Latin words out of the list, or use word-boundary matching for ASCII entries, and maintain an explicit allowlist for known-safe strings (place names, surnames, product SKUs).
Multibyte and encoding. Ensure the input is valid UTF-8 before scanning and that the guard is using mb_* functions internally. Data coming from legacy imports, CSV uploads, or truncated columns can silently produce zero matches.
Trie memory vs. dictionary size. The singleton keeps the whole trie in memory per PHP process. A 100k-word list is fine; a multi-megabyte one multiplied across Octane workers or many FPM children is not. Measure before shipping a huge dictionary, and remember that under Octane a dictionary update requires a worker reload.
A match is not a verdict. Log the matched words (not just the boolean) so you can tune the list. Keep those logs private — dumping your dictionary into an exception message or API error response hands attackers the bypass map.
Laravel 13 note. 1.0.4 is the first release that allows Laravel 13. If your composer.json pins the framework or the package at 1.0.3, upgrade the package constraint alone; no application code changes are expected. Verify with a quick run of your moderation test suite against the new framework version, since that is the only variable that changed.
How can I help you explore Laravel packages today?