yasser-elgammal/pure-text
Pure Text is a lightweight Laravel package that converts rich HTML content into clean, readable plain text. Strip tags, decode entities, normalize whitespace, and generate search-friendly excerpts for notifications, previews, and indexes through a simple, expressive API.
Install and pin v2
composer require "yasser-elgammal/pure-text:^2.0"
php artisan vendor:publish --tag=pure-text-config # re-check the exact tag in the v2 README
v2 is a major bump, so treat this as a migration rather than a patch. The release notes call out three things — Unicode/Arabic support, refactoring, and new features — none of which come with a documented BC-break list. Diff before you commit:
php artisan vendor:publish --tag=pure-text-config --force # then git diff config/pure-text.php
composer diff # or diff vendor/.../src between the old and new tag
Where to look first
config/pure-text.php — the most likely place for breaking changes. Expect new or renamed keys around normalization and locale. Re-publish to a temp path and diff against your committed config rather than assuming old key names still resolve.src/ — v2 was refactored, so class names, namespaces, and strategy constructor signatures may have moved. If you subclassed or type-hinted any package class in v1, grep your codebase for those names before running tests.First use case: clean user-generated text on write
use YasserElgammal\PureText\Facades\PureText;
$post = Post::create([
'title' => $request->input('title'),
'body' => PureText::filter($request->input('body')),
]);
If the facade alias changed in v2 (refactors often move facades), resolve from the container (app('pure-text')) or use the helper the README documents. String in, filtered string out is the stable contract across both majors.
Migrating from v1
Run your existing sanitizer tests against v2 before touching production. Pay particular attention to Arabic fixtures — if v2 now normalizes input internally, strings that previously passed through unchanged may now match and be masked.
FormRequest: sanitize before validation
protected function prepareForValidation(): void
{
$this->merge([
'body' => PureText::filter((string) $this->input('body')),
]);
}
Still the best default. Filtering here means max: rules run against the post-filter length, which matters because masking preserves length and removal does not. Keep this scoped to genuine free-text fields — emails, slugs, and base64 payloads should never see the filter.
Model mutator for single-field models
public function setBodyAttribute($value): void
{
$this->attributes['body'] = PureText::filter((string) $value);
}
Fine for one field. At five fields, move to a FormRequest or an explicit service call.
Blade directive as an escape hatch
@pureText($comment->body)
Use only for legacy rows that predate your filter. Verify echo-escaping semantics against {{ }} before using it inside attributes — and re-verify after the v2 refactor, since directive registration is exactly the kind of thing a refactor touches.
Middleware for route groups
Route::middleware(['auth', 'pure-text'])->group(function () {
Route::post('/comments', [CommentController::class, 'store']);
});
Confirm the alias is still registered under the same name in v2. Request-rewriting middleware touches every field in the payload by default — test with a request containing a URL and a code snippet to confirm they survive.
Per-locale or per-tenant word lists
config(['pure-text.words' => config('pure-text.words.ar')]);
$clean = PureText::filter($text);
Wrap this in a TextSanitizer service resolving the list from locale or tenant, so the config mutation isn't scattered across controllers. Config mutations are per-request only and are lost or leaked under Octane and long-lived queue workers. If v2 added a first-class locale option, prefer that over mutating config.
Choose the strategy deliberately
Set it once at config level. Only switch per call site if v2 exposes a documented per-call option.
Decide whether to keep your own normalizer
This is the main design question v2 forces. If the package now folds Alef variants, ى/ي, ة/ه, harakat, and tatweel before matching, delete your custom normalizer — running both wastes cycles and can double-transform. If it only supports Unicode input without normalizing, keep your layer. Read the source of the normalization path, don't infer it from the changelog line.
Store both raw and filtered when moderation matters
Add a body_raw column if filtered posts may need review or appeal. Filtering is lossy and irreversible. This decision costs one column and saves you a data-loss incident.
The v2 changelog is not a migration guide
"Unicode/Arabic support, refactoring, and new features" tells you nothing about what broke. The practical checklist: re-publish and diff the config, grep for package class names you referenced, re-run your sanitizer suite, and smoke-test the login and comment flows. Do this on a branch, not on main.
Namespace and strategy class churn from the refactor
Any use statement pointing at package internals is at risk. This is the argument for wrapping the filter in your own TextSanitizer service: one file to fix instead of dozens of call sites. If you were subclassing a strategy class, v2 is a good moment to stop.
Arabic normalization — verify, don't assume
The feature is announced, but "supports Arabic" previously meant only "handles UTF-8 correctly." Test explicitly with:
أ إ آ اى → ي, ة → هـIf a blocked word still slips through, it's an unnormalized variant, not a broken regex. Add both normalized and raw forms to the list, or normalize before calling the filter.
The Scunthorpe problem
Substring matching blocks innocent words containing a blocked sequence. Check whether the config exposes whole-word or boundary matching. Whole-word is usually right for Latin; for Arabic, clitics (و, ال, ب) make \b unreliable, so a prefix-aware rule serves better.
Regex escaping and catastrophic backtracking
If you build patterns from the list, preg_quote($word, '/') every entry. One unescaped . or * in a custom word becomes a wildcard, and a pathological entry can blow up on long input. Cap input length before filtering long-form submissions.
Config caching
After editing the word list in production, run php artisan config:cache. And the reverse: when config is cached, runtime config([...]) overrides apply per-request but won't persist — which surprises people under queue workers that reuse state.
Performance: filter once, not per read
Filtering is regex work proportional to text length. In an accessor or Blade directive, every list page and API response pays for it. Filter on write, store the result, reserve render-time filtering for legacy data.
Middleware scope creep
Request-rewriting middleware applies to all input, including password, _token, and file metadata. Scope it to specific routes or verify the package skips non-string and known-safe keys. Re-test registration and login after upgrading — a filtered password field is a support nightmare.
Version pinning
Pin "^2.0" and read the changelog before every minor. With a low-traffic package you are part of the QA surface; the v2 release notes are evidence enough that you can't rely on upstream to enumerate impact.
Testing and extension points
Assert both directions: a blocked word is transformed, and a benign string passes through unchanged. Include at least one Arabic string with a diacritic and one with an Alef variant — those are exactly the cases v2 claims to fix and exactly the cases most likely to regress. Keep the filter behind your own thin service rather than subclassing package internals; the strategy classes are the likeliest thing to change again.
How can I help you explore Laravel packages today?