vangrg/profanity-bundle
vangrg/profanity-bundle adds configurable profanity filtering to PHP applications, detecting and masking offensive words in user input and text. A lightweight way to clean comments, usernames, and messages before storage or display.
Read the source before you require it. This is a Symfony bundle (4 stars, last release 2019-02-20, zero dependents), not a Laravel package. The bundle is a thin wrapper: almost all behaviour lives in the base library vangrg/profanity, and the bundle just registers one service and maps a dictionary config key to a file path. For a Laravel app you want the library, not the bundle.
composer require vangrg/profanity
Only install vangrg/profanity-bundle if you are genuinely sharing code with a Symfony app — it pulls in symfony/http-kernel and symfony/dependency-injection, and its ProfanityBundle class extends Symfony\Component\HttpKernel\Bundle\Bundle, which means php artisan will never auto-discover it. There is no ServiceProvider, no facade, no publishable config.
vendor/vangrg/profanity/src/Profanity.php. It is ~200 lines and is the entire documentation. Confirm the method signatures (check(), filter(), addWord(), setDictionary()) and, critically, the boolean sense of check() — verify whether true means "clean" or "contains profanity" before you wire it into a validator.composer update.Bind it once in App\Providers\AppServiceProvider:
$this->app->singleton(\Vangrg\Profanity\Profanity::class, function () {
return new \Vangrg\Profanity\Profanity(
config('profanity.dictionary_path')
);
});
Add config/profanity.php with dictionary_path (default resource_path('profanity/dictionary.txt')), replacement, and a whitelist array. Resolve the path with resource_path()/base_path() at runtime — never bake an absolute path into config, or config:cache on deploy will hand you a stale one.
Custom validation rule (the 90% case). Wrap the checker in a Illuminate\Contracts\Validation\Rule so it composes with the rest of your FormRequest rules:
class CleanText implements ValidationRule
{
public function __construct(private Profanity $checker) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (is_string($value) && $this->checker->check($value)) {
$fail("The :attribute contains prohibited language.");
}
}
}
Resolve via the container (app(CleanText::class)) so the singleton — and its parsed dictionary — is reused.
Mask on display, don't just reject on input. Store the raw text, render a filtered version. A Blade directive keeps call sites tidy:
Blade::directive('clean', fn ($expr) => "<?php echo e(app(Profanity::class)->filter($expr)); ?>");
Deferred moderation instead of hard failure. Rather than 422-ing every submission, write the row and push a FlagForReview job when check() is true. You keep the audit trail (store the matched term and dictionary version in a moderation_flags table), and you avoid telling an attacker which exact token tripped the filter.
Retroactive scans belong in queue jobs. Dictionary contents change; old rows were validated against an older list. A nightly ModerateExistingContent job using ->chunkById(500) is the right shape — never a synchronous full-table scan in a request.
Tenant/domain dictionaries. For multi-tenant apps, keep the base dictionary shared and call addWord() per tenant inside a scoped binding, or subclass Profanity and setDictionary() to a per-tenant file. Load it lazily; a per-request file parse is measurable on high-RPS endpoints.
Testing. Never let tests hit the real dictionary. Bind a fake in TestCase::setUp():
$this->swap(Profanity::class, new class extends Profanity {
public function check(string $text): bool { return str_contains($text, 'badword'); }
});
Then assert on your rule and job behaviour, not on the library's word list.
symfony/* versions that clash with Laravel 10/11's Symfony 6/7 requirements. If composer require explodes, use vangrg/profanity (far fewer constraints) or vendor the single Profanity.php class into app/Support/.each(), create_function, unguarded strpos() null returns, and implicit nullable parameters before adopting. Test on your actual PHP version, not just CI's.assassin, class, grape, shiitake, Penistone. Always keep a whitelist and prefer masking over blocking. A blocked user is a lost user; a masked word is a cosmetic bug.sh1t, f*ck), spacing (f u c k), and Cyrillic/Greek homoglyphs will all slip through plain strtolower() matching. Normalize with mb_strtolower and consider Normalizer::normalize($text, Normalizer::FORM_KC) — but accept that you are doing best-effort filtering, not security.check() on day one. Getting it inverted means either a filter that does nothing or one that blocks everything. Write one test asserting a known-clean and a known-dirty string before building anything on top.singleton (see above) or wrap the parse in Cache::rememberForever.addWord(), setDictionary(), and the dictionary config key are essentially the whole surface. If you need tokenizer-aware matching, stemming, or per-locale dictionaries, subclass Profanity and keep your additions behind your own interface rather than scattering calls to the vendor class through the codebase.How can I help you explore Laravel packages today?