overtrue / laravel-text-guard
TextGuard:可作 Rule 也可单用的字符串清洗/规范化工具
Fund package maintenance!
Requires
- php: >=8.3
- ext-intl: *
- ext-mbstring: *
- illuminate/support: ^12.0|^13.0
Requires (Dev)
- jetbrains/phpstorm-attributes: ^1.0
- laravel/pint: ^1.24
- mockery/mockery: ^1.6
- orchestra/testbench: ^10.0|^11.0
- phpunit/phpunit: ^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-10 13:33:44 UTC
README
Composable string filtering and explicit validation for Laravel.
Install
Requires PHP 8.3+, Laravel 12/13, mbstring and intl. HTML whitelist filtering additionally requires ext-dom.
composer require overtrue/laravel-text-guard php artisan vendor:publish --tag=text-guard-config
Compose operations, not business policies
Every step is a string-to-string operation. Use a preset for reuse, or compose an ordered pipeline directly:
use Overtrue\TextGuard\TextGuard; $clean = TextGuard::pipe($input, [ 'html_decode' => true, 'strip_html' => true, 'unicode_normalization' => 'NFC', 'remove_bidi_controls' => true, 'remove_zero_width' => ['preserve_joiners' => true], 'collapse_spaces' => true, 'trim_whitespace' => true, 'truncate_length' => ['max' => 80, 'unit' => 'grapheme'], ]); $clean = TextGuard::filter($input, 'safe'); $clean = TextGuard::filter($input, 'safe', ['truncate_length' => ['max' => 100]]); $cleanByKey = TextGuard::batchFilter(['title' => $input], 'safe');
Steps execute in the supplied order, not registry order. A preset is just a named ordered configuration. There are no built-in phone thresholds, domain suffix lists, email-specific replacements or filename policies.
trueenables defaults;falsedisables a step. A string or array (including[]) is passed directly to the constructor.- Overrides recursively merge associative options. Lists replace whole lists, including
[]to clear an allowlist. - Existing step positions are retained by overrides; new steps append. Use
pipe()or a new preset to change order. Register the same class under different names to run it more than once. - Unknown presets/steps and invalid configuration types throw
InvalidArgumentException. An explicitly configured empty preset orpipe($input, [])is an identity operation. filter()andpipe()reject invalid UTF-8. Configuration is trusted application code, not user input.
For non-Facade usage, instantiate TextGuardManager with the configuration array. Its string operations do not read the HTTP request.
Choose filtering or rejection explicitly
Filtering returns a new string without mutating input or the HTTP request. Validation checks whether the configured pipeline leaves the string unchanged:
$result = TextGuard::validate($input, 'safe'); // ['valid' => bool, 'errors' => array] use Overtrue\TextGuard\Rules\Filtered; $validator = validator($data, [ 'items.*.name' => ['required', 'string', new Filtered('safe')], ]); $accepted = $validator->validated();
Filtered rejects values that need filtering and never writes to request(). It supports standalone validators and nested/wildcard fields. mustNotBeEmpty: false permits an unchanged empty string, not whitespace that would be removed. The optional overrides argument has the same semantics as filter().
Nothing in this package rewrites the HTTP request unless you opt in. Pick one path:
| Goal | Approach |
|---|---|
| Filter a string in application code | TextGuard::filter() / pipe() |
Replace repeated prepareForValidation() boilerplate |
$request->mergeFiltered([...]) on Request / FormRequest |
Filter during validation, keep validated() clean |
Sanitize rule (validator data only) |
| Reject input that still needs filtering | Filtered rule (unchanged-input check) |
| Filter specific route inputs before the controller | Opt-in text-guard.merge middleware |
mergeFiltered() on requests
After the service provider boots, every Request exposes mergeFiltered(array $fieldPresets). Each key is an attribute pattern; each value is a preset name. Matching string fields with valid UTF-8 are filtered and merged back onto that request instance only.
$request->mergeFiltered([ 'name' => 'safe', 'bio' => 'nickname', 'items.*.title' => 'safe', // wildcard patterns use Str::is() on dotted keys ]);
Non-string and invalid UTF-8 values are left unchanged (no surprise exceptions). Unknown presets and invalid configuration still throw, same as filter().
Equivalent manual cleanup in a FormRequest:
protected function prepareForValidation(): void { $this->mergeFiltered(['name' => 'safe', 'bio' => 'nickname']); } public function rules(): array { return ['name' => ['required', 'string', new Filtered('safe')]]; }
Sanitize vs Filtered
Sanitize runs the configured pipeline and writes the cleaned string into the validator data so validated() returns filtered values. It never calls request()->merge().
use Overtrue\TextGuard\Rules\Sanitize; use Overtrue\TextGuard\Rules\Filtered; $validator = validator($data, [ 'nickname' => ['required', 'string', new Sanitize('username'), new Filtered('username')], ]); $clean = $validator->validated(); // already filtered
Use Filtered alone when clients must submit already-clean text. Pair Sanitize with Filtered when you want automatic cleanup plus an unchanged-input guarantee after sanitization.
Opt-in route middleware
The package registers the alias text-guard.merge only. It is not global middleware. Attach it to routes that should clean declared fields:
Route::post('/profile', ...) ->middleware('text-guard.merge:name:safe,bio:nickname');
Parameters use field:preset syntax. Only listed fields are touched.
Use Laravel's required, nullable and length rules for business requirements. Invalid types/UTF-8 fail the Filtered rule; configuration errors still throw. Sanitized(minVisibleRatio: 0.6, minLength: 1) remains a visibility heuristic, not a guarantee that text is filtered or safe to render.
Independent Unicode policies
remove_zero_widthremoves U+200B/U+200C/U+200D/U+FEFF/U+2060 by default.preserve_joiners: truekeeps ZWNJ/ZWJ for writing systems and emoji; it does not validate their placement.remove_bidi_controlsremoves Unicode Bidi_Control characters while preserving ordinary RTL letters and joiners. It is opt-in because directional formatting can be intentional.truncate_lengthdefaults to code points. Chooseunit: graphemefor user-perceived characters, combining marks and emoji sequences.max: 0returns an empty string; invalid maxima/units throw.character_whitelistrecognizes pictographic ZWJ sequences, modifiers, regional-indicator pairs, keycaps and emoji tag sequences as units. It is not an RGI emoji validator. Recognition depends on runtime Unicode tables; grapheme truncation uses ICU.
The nickname example preset preserves joiners, uses grapheme limits and omits the code-point visibility heuristic. A later step can still undo an earlier decision: preserving joiners does not override a character whitelist. Choose operations appropriate to the text's language and purpose.
Reusable presets and extensions
Built-ins: safe, strict, username, nickname, rich_text. Exact definitions live in config/text-guard.php; names are convenience labels, not security guarantees.
use Overtrue\TextGuard\Pipeline\PipelineStep; class ReplaceText implements PipelineStep { public function __construct(private array $replacements = []) {} public function __invoke(string $text): string { return strtr($text, $this->replacements); } } TextGuard::registerPipelineStep('replace_text', ReplaceText::class); $display = TextGuard::pipe($input, [ 'strip_html' => true, 'replace_text' => ['[' => '(', ']' => ')'], ]);
This example is literal replacement, not HTML/Markdown escaping. Domain-specific detection and masking belong in separately composed custom steps. Replacement choices should not force normalization, storage or rendering policies.
Introspection: getAvailableSteps(), getAvailablePresets(), getPresetConfig($name).
Eloquent integration is opt-in
use Illuminate\Database\Eloquent\Model; use Overtrue\TextGuard\TextGuardable; class User extends Model { use TextGuardable; protected $textGuardFields = ['name' => 'nickname', 'bio' => 'safe']; }
Only dirty, non-null configured attributes are filtered on saving. Lists of field names use getTextGuardDefaultPreset(); associative entries specify presets. Mixed lists are supported. Unknown preset names throw instead of falling back silently.
Override getTextGuardFields() / getTextGuardDefaultPreset() instead of properties if needed. filterField($field, $preset) returns a value without assigning it; getTextGuardFieldNames() returns normalized names. disableTextGuard() / enableTextGuard() affect all models in the process. withoutTextGuard($callback) restores the previous state on success or exception. Do not use process-wide toggles across concurrent requests.
Do not persist display-only masking unless overwriting the original value is intended.
Rendering and composition boundaries
strip_html is tag removal, not contextual output encoding. html_decode performs one pass. Put decoding before tag removal when that is the desired contract; do not silently decode repeatedly until stable. Arbitrary pipelines are not guaranteed to be idempotent.
Escape at the HTML/attribute/JavaScript/Markdown output boundary. HTML whitelist filtering requires ext-dom rather than degrading to an attribute-preserving tag stripper. Allowlists replace defaults. Do not decode or normalize HTML after sanitization without reconsidering the final output. String truncation is not DOM-aware; for rich text, reject oversized input or use DOM-aware truncation before final sanitization.
Upgrading: breaking changes
This redesign requires a breaking release:
- Execution follows preset order. Audit configurations relying on
pipeline_maporder; decoding before tag removal now works as documented. Filteredno longer cleans global request data: it rejects dirty input. UsemergeFiltered(),Sanitize, or explicitTextGuard::filter()/prepareForValidation()when cleanup is intended.- New opt-in helpers (
mergeFiltered(),Sanitize,text-guard.mergemiddleware) never run unless you call or attach them. validate()checks the complete pipeline, including custom steps, rather than a hard-coded subset. Empty configured presets are valid.- Unknown names, invalid step types and invalid UTF-8 fail explicitly. Use
false, notnull/0, to disable a step;[]enables defaults. - Numeric option lists replace rather than merge. Review published allowlists. Model preset typos no longer fall back to the default.
- Remove
emoji_rangesfrom published config.allow_emojinow handles Unicode sequences; obsolete range options throw. Use a custom step for application-specific symbol restrictions. nicknamenow measures graphemes, retains joiners and omits the visibility heuristic. Other presets retain their character/length policies. Built-in presets now finish with trimming, so tag removal, zero-width removal or truncation cannot leave exposed leading/trailing whitespace.- HTML whitelist filtering requires ext-dom instead of silently degrading.
For coding agents
Use this section when integrating or extending the package. Do not invent APIs from memory or from 1.x behavior.
Scope
- For: normalizing and validating user-supplied plain/rich strings via composable pipelines and Laravel-friendly opt-in hooks.
- Not for: replacing output escaping (Blade
{{ }},e(), CSP, CSRF), authorization, rate limits, or storage encryption. Filtering here does not make HTML/JS safe to echo without boundary escaping.
Pick one integration path
| Need | Do this |
|---|---|
| Clean a string in code | TextGuard::filter() or TextGuard::pipe() |
Clean specific request keys on this Request / FormRequest |
$request->mergeFiltered(['field' => 'preset', 'items.*.name' => 'safe']) in the controller or prepareForValidation() |
Clean during validation and return filtered values from validated() |
Put new Sanitize('preset') before new Filtered('preset') on the same attribute |
| Reject input that still needs filtering (client must submit already-clean text) | new Filtered('preset') only |
| Clean declared fields on specific routes | Route middleware text-guard.merge:field:preset,... (alias registered by the package; not global) |
| Clean on Eloquent save | TextGuardable with $textGuardFields — only when the app owns that model |
Sanitize writes into validator data only (via ValidatorAwareRule). Filtered is a pure unchanged-input check and never mutates request().
Sanitized is unrelated: it is a visibility-ratio heuristic, not pipeline filtering. Do not substitute it for Sanitize or Filtered.
Wildcards in mergeFiltered() use Arr::dot + Str::is on dotted keys (same idea as items.*.name). Middleware parameters are comma-separated field:preset pairs (only the first : splits); prefer mergeFiltered() when field patterns contain :.
Unknown presets and invalid pipeline configuration throw InvalidArgumentException. mergeFiltered() skips non-strings and invalid UTF-8 without throwing; Filtered fails those cases; Sanitize skips them without failing.
Do not
- Register
text-guard.mergeas global middleware. - Call
request()->merge()from inside a validation rule. - Assume
Filteredstill cleans the request like 1.x — it rejects dirty input instead. - Confuse
Sanitize(pipeline +validated()mutation) withSanitized(invisible-character ratio). - Put
FilteredbeforeSanitizeon the same attribute (order matters). - Rely on
emoji_rangesor other removed 1.x config keys.
1.x → 2.x facts agents must not miss
- Pipeline steps run in preset definition order, not
pipeline_mapregistration order. Filteredno longer mutates global request data.mergeFiltered(),Sanitize, andtext-guard.mergerun only when the application opts in.- List-style overrides (HTML allowlists, etc.) replace the whole list;
[]clears a list. - Model preset typos throw instead of falling back to the default preset.
- Remove
emoji_rangesfrom config; usecharacter_whitelist/allow_emojiand custom steps instead.
Extending safely
- Implement
PipelineStepwith__invoke(string $text): string. - Register with
TextGuard::registerPipelineStep('step_name', YourStep::class). - Reference the step in
config/text-guard.phpunderpipeline_mapand in preset arrays (true,'NFKC', or[...]per step). - Use built-in preset names from config (
safe,strict,username,nickname,rich_text) or define new presets — do not invent undocumented keys.
Human maintainers: project-wide agent maintenance rules live in AGENTS.md; integration rules for agents are defined in this section.
Development
composer install
composer fix
composer test
MIT License.