ivanbaric / sanigen
Declarative sanitization and attribute generators for Eloquent models.
Requires
- php: ^8.2
- illuminate/support: ^12.0 || ^13.0
- symfony/html-sanitizer: ^7.4
- symfony/polyfill-intl-normalizer: ^1.31
Requires (Dev)
- laravel/pint: ^1.0
- orchestra/testbench: ^10.0 || ^11.0
- pestphp/pest: ^3.8 || ^4.0
- phpstan/phpstan: ^1.10
- spatie/laravel-translatable: ^6.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Sanitize Eloquent attributes and generate values with rules declared on your model.
Combine text and HTML filters, clean nested JSON and translations, and generate slugs, identifiers or dates—without repeating that logic in controllers. Use built-in rules, reusable config aliases or your own classes.
Model + config · All filters · All generators · Recursion and JSON · Spatie Translatable · Custom generators
This guide covers the structured API introduced in Sanigen 1.9.0. The older recursive: syntax is explained below for existing projects.
Model + config
A model needs one trait and two optional rule maps:
use Illuminate\Database\Eloquent\Model; use IvanBaric\Sanigen\Traits\Sanigen; class Post extends Model { use Sanigen; protected $fillable = ['name', 'email']; protected array $sanitize = [ 'name' => 'strip_html|trim|squish', 'email' => 'email', ]; protected array $generate = [ 'slug' => 'slugify:name', 'uuid' => 'uuid', ]; }
$sanitize applies filters from left to right. $generate fills null attributes when a model is created. Use either map independently; neither is mandatory.
$post = Post::create([ 'name' => ' <b>Hello</b> world ', 'email' => ' USER@EXAMPLE.COM ', ]); $post->name; // 'Hello world' $post->email; // 'user@example.com' $post->slug; // 'hello-world' if available, otherwise a collision suffix $post->uuid; // A generated UUID v4
The example needs name, email, slug, uuid and timestamps in the posts table. Sanigen does not create columns. Add database unique indexes for values that must be unique.
Config is for reuse, not a requirement for every filter. Give a pipeline a name in the published config, then use that name on any model:
// config/sanigen.php — add to the existing aliases array. 'aliases' => [ // Keep the other aliases you use. 'clean_name' => 'unicode|strip_html|trim|squish', ],
// In a model using Sanigen: protected array $sanitize = [ 'name' => 'clean_name', ];
An alias is just a reusable combination of the filters listed below. You can still declare a pipeline directly on the model.
Installation
Requires PHP 8.2+ and Laravel 12 or 13, subject to Laravel's own PHP requirements.
composer require "ivanbaric/sanigen:^1.9"
The provider is auto-discovered and the default config is loaded automatically. Publish it when you want to add aliases or change settings:
php artisan vendor:publish --provider="IvanBaric\Sanigen\SanigenServiceProvider" --tag="config"
The Spatie integration is optional and requires spatie/laravel-translatable separately.
Available filters
Filters are sanitizer rules. Combine them with |, for example trim|upper or strip_html|trim|squish. A custom alias can use any of these rules.
| Rule | Behavior | Example |
|---|---|---|
unicode |
Normalizes valid Unicode to NFC without transliteration | Decomposed c + combining caron → č |
normalize_newlines |
Converts Windows and old Mac line endings to \n |
"A\r\nB\rC" → "A\nB\nC" |
trim |
Removes leading and trailing whitespace | " Hello " → "Hello" |
lower |
Converts text to lowercase | "Hello" → "hello" |
upper |
Converts text to uppercase | "hello" → "HELLO" |
ucfirst |
Capitalizes the first UTF-8 character, leaving the rest unchanged | "žetva" → "Žetva" |
squish |
Collapses whitespace to single spaces | "Hello World" → "Hello World" |
strip_newlines |
Removes line breaks without inserting spaces | "Line 1\nLine 2" → "Line 1Line 2" |
strip_html |
Drops dangerous elements, then removes HTML tags | "<p>Hello</p>" → "Hello" |
strip_tags |
PHP strip_tags() with configured allowed tags; not an XSS boundary |
"<p>Hello</p>" is retained by the default allowlist |
strip_scripts |
Parser-based HTML cleanup, then configured tag filtering | "<script>alert(1)</script>Hello" → "Hello" |
safe_html |
Parser-based rich HTML sanitization | Keeps safe formatting and removes unsafe markup |
strip_emoji |
Removes complete displayed emoji sequences, including keycaps and joined emoji; keeps ordinary digits and text symbols | "Hello 👩💻 World" → "Hello World" |
alpha |
Keeps letters only | "Hello123" → "Hello" |
alnum |
Keeps letters and numbers | "Hello123!" → "Hello123" |
alpha_dash |
Keeps letters, numbers, hyphens and underscores | "Hello-123_!" → "Hello-123_" |
ascii |
Removes non-ASCII characters; does not transliterate | Base rule: "cafe ć" → "cafe "; default alias also trims |
digits |
Keeps digits only | "Price: $123.45" → "12345" |
decimal |
Cleans numeric text, normalizes separators and preserves a negative sign; repeated three-digit groups are treated as thousands | "-12,50 €" → "-12.50" |
email |
Filters email text; the default alias also trims and lowercases | Default alias: " USER@EXAMPLE.COM " → "user@example.com" |
phone_clean |
Keeps digits and an existing leading + |
"(123) 456-7890" → "1234567890" |
url |
Validates and normalizes configured HTTP/HTTPS URLs | "example.com" → "https://example.com" |
slug |
Produces a URL-friendly string | "Hello World" → "hello-world" |
Examples above use the shipped config. Some names (email, ascii, decimal, url, slug, safe_html) also have default aliases; their configured pipelines run when you use those names. To inspect a primitive without its alias, use SanitizerRegistry::resolveBaseSanitizer().
For url, allowed_url_schemes must be a nonempty list of valid scheme names, and default_url_scheme must be a valid name in that list. The shipped defaults allow only http and https; malformed configuration raises an error instead of silently emptying a valid URL.
For email, valid internationalized local parts such as žetva@example.test are preserved after normalization. Malformed UTF-8 and Unicode addresses PHP cannot validate (including unconverted internationalized domains) are rejected instead of silently becoming a different recipient. ASCII input keeps the existing cleanup behavior; this filter is not a full email validator.
The unicode filter only normalizes valid Unicode to NFC. It preserves Croatian letters; it does not transliterate or repair broken legacy encoding. ascii deliberately removes non-ASCII characters.
Email and phone filters normalize text; they do not verify deliverability or phone-number validity. decimal keeps an ASCII or Unicode minus sign only when it precedes the first digit and normalizes common separators, but it is not locale-aware number validation. The default URL rule rejects unsupported schemes such as javascript:, data:, file: and ftp: by returning ''.
Ready-made aliases
The config ships these combinations, which you may customize:
| Alias | Intended use |
|---|---|
text |
Plain, single-line text with normalized whitespace |
plain_text |
Plain text preserving line breaks, normalized to \n |
title |
Plain text lowercased, then its first character capitalized |
ascii |
Plain text restricted to ASCII, with surrounding spaces trimmed |
safe_html |
Unicode normalization followed by rich HTML sanitization |
email |
Trimming, lowercasing and email-text cleanup |
url |
Trimming and URL normalization; embedded line breaks are rejected |
slug |
Trimming, lowercasing and slug formatting |
decimal |
Trimming and decimal-string normalization |
phone |
Trimming and phone-character cleanup |
See config/sanigen.php for the exact pipelines. Aliases may reference other aliases or registered custom filters. Circular aliases throw an exception. Alias names cannot be empty, have surrounding whitespace or contain | (the pipeline separator); invalid configured names raise an error instead of silently applying a different rule. An internal space remains allowed.
An alias you use must contain at least one filter name in a string pipeline. An empty, separator-only or non-string definition raises a configuration error rather than silently leaving input untouched.
Filter order matters. Two filters that are individually stable can still change the value again if the cleaned value is later reassigned or resanitized. Put removal filters before final whitespace cleanup: use strip_emoji|trim, not trim|strip_emoji; use strip_scripts|strip_html, not the reverse, when both are needed. Test your own pipelines with reassignment and a sanigen:resanitize --dry-run before applying them to stored data.
Available generators
Use name:parameter when a generator takes an argument. For example, slugify:name reads the model's name field, and random_string:16 creates 16 characters.
| Rule | Generates |
|---|---|
uuid |
A UUID v4 |
uuid:v7 |
A UUID v7 |
uuid:v8 |
A UUID v8 |
ulid |
A ULID |
autoincrement |
The next numeric value based on the model query |
unique_string:8 |
A random 8-character string, checked against existing field values |
random_string:16 |
A random 16-character string, without a uniqueness check |
slugify:name |
A slug derived from name, with an incremental suffix on collision |
slugify:name,date |
A slug derived from name, with a date suffix on collision |
slugify:name,uuid |
A slug derived from name, with a UUID suffix on collision |
carbon:+7 days |
A Carbon date from the given relative modifier |
user:id |
The authenticated user's id; user:email reads email |
UUID versions are limited to v4, v7, and v8 (case-insensitive). An explicitly empty or unsupported version raises an error before a model is saved instead of silently generating v4.
carbon and its offset alias use Carbon's date modifier syntax in the application's timezone. A blank modifier is rejected instead of being treated as the current time.
The built-in slug suffix modes are increment, date and uuid. An unknown mode or an extra comma-separated argument raises a clear error before a misleading slug is saved. A subclass that overrides slug generation or suffix selection can keep its own custom mode.
Compatibility names remain available: unique_code:8 maps to unique_string:8, offset:+7 days maps to carbon:+7 days, and auth_id:id maps to user:id. Prefer the current names in new code. Omitting the parameter uses the generator's declared default: random_string makes 8 characters, unique_string/unique_code make 6, carbon/offset use +7 days, and user/auth_id read id. Built-in random and unique-string lengths must be positive whole numbers and may not exceed max_generated_string_length (default 65535); invalid lengths throw before allocating a string or inserting a row. Raise that setting explicitly if your application needs longer codes. Invalid date modifiers raise a RuntimeException before insertion. user:property distinguishes a missing property from one that exists with a null value; a nullable destination column is required to store that value.
Generators run during Eloquent's creating event and only replace null. An existing value—including '', 0 or false—is preserved. The user generator throws if the user or requested property is missing; it does not authorize ownership or tenant access.
Eloquent's saveQuietly(), updateQuietly(), pushQuietly() and withoutEvents() skip model events. Attribute assignment is still sanitized, but generators do not run. If slug_updates_on_save is enabled, a quiet title update therefore leaves the previous slug in place; use a regular save when the slug must follow the new title, or set the slug explicitly when deliberately suppressing events. pushQuietly() also suppresses creation events for related models: a newly created child may be saved without its generated UUID or slug. A later ordinary update does not backfill creation-only fields such as UUID; opt-in slug updates can regenerate a slug only when its source changes. Prefer a regular push() when related records need generated identifiers.
When copying a model with Eloquent's replicate(), exclude generated fields that need fresh values. Otherwise the copy retains the original slug and UUID; a unique database index can reject it:
$copy = $post->replicate(['slug', 'uuid']); $copy->save(); // Sanigen generates a new slug and UUID.
For JSON-arrow targets such as settings->code, the existing value is read from that exact JSON leaf, not from the literal settings->code model property. Already supplied values remain intact; a missing or null leaf is generated.
Existing-value checks are not an atomic uniqueness guarantee. Add the appropriate database index (for example, ['team_id', 'slug'] for a tenant-scoped slug), scope the model query correctly and handle a concurrent uniqueness conflict at the application's write boundary.
When Sanigen generates a slug for an Eloquent model, it checks availability and reserved-path rules against the value after its configured sanitizer and deterministic Eloquent set mutator, cast or custom setAttribute override run, including on opted-in slug updates. Suffix attempts remain bounded. These setters may run for a candidate preview and again on the accepted assignment, so keep them pure and deterministic; if the actual stored value differs from the preview, the save fails before the database write. Direct calls to the slug generator still return the raw slug; keep a database unique index because simultaneous writes can still race.
Slug generation rejects malformed UTF-8 in the source field before transliteration, even if the source has no sanitizer or only the generated slug has one. Valid multilingual text is transliterated as before.
If the first model creation is cancelled, a later generator fails, or a database uniqueness constraint rejects a racing value, retrying that same unsaved model recomputes its automatically generated slug and checks availability again. The application must catch the database conflict and start that retry; Sanigen does not retry writes automatically. A slug supplied or replaced by your code remains yours. unique_string likewise rechecks a generated code when the same model is retried.
If you disable a generator between a cancelled save and a retry, Sanigen discards only the value it generated for that cancelled attempt. On updates it restores the previously stored value; other edited fields and explicitly replaced values remain unchanged. This also applies to nested JSON generator targets.
For a new nested JSON target, an explicit null key is restored as null rather than removed; a key that was absent before generation remains absent.
That same-instance retry is safe only while Eloquent still considers the model unsaved. If a saved listener throws after insertion inside saveOrFail(), the transaction rolls back, but Eloquent may leave the in-memory model marked as existing. Retrying saveOrFail() on that same object can return true while updating no row. This also happens with an ordinary Eloquent model without Sanigen. After such a post-insert rollback, verify the database state and rebuild a fresh model before retrying the create; do not treat a second true return alone as proof that the row exists.
For a nested JSON code, Sanigen checks the exact nested candidate even if another literal key contains dots. Use a database constraint on the relevant JSON expression where supported; application-side checks alone cannot close concurrent races.
Nested Spatie translation keys such as settings->a->b likewise use the exact nested map. A separate literal key named a.b remains untouched when you add, replace or remove a locale from the nested map.
Since 1.9.1, database-backed generators retain the model instance's connection, table and global scopes. During Sanigen's creating event, unique_string and unique_code check the filtered candidate before assignment. Empty filtered candidates are retried within the configured attempt limit, never saved as codes. Rejected candidates do not change the model; the accepted value is not filtered again. JSON-arrow targets use the selected leaf, and Spatie targets use the current locale. Native mutators and events still run for the accepted value.
Direct calls to UniqueStringGenerator::generate() keep the raw-string API. A custom subclass overriding generate() keeps its own behavior and uniqueness policy. Deterministic Eloquent set mutators, casts and custom setAttribute overrides are included in the checked value for model-generated codes. Keep database constraints on the final stored value and its tenant/locale scope to protect against simultaneous writes.
Generated slugs stay stable on updates by default. To regenerate when the source changes, a model may declare:
protected bool $slugUpdatesOnSave = true;
Date and UUID suffixes are added only on collision. See slug behavior for configuration details and the optional length/reserved-path hooks added on the current source branch after stable 1.9.0.
Recursion and JSON
Since 1.9.0, every sanitizer rule automatically recurses through arrays. It cleans matching strings while preserving keys, structure, integers, floats, booleans and null. Your custom sanitizer still receives one string at a time; it does not need to implement recursion.
The configured scalar byte limit applies to new raw text, including JSON-path writes and new Spatie translations. Output expanded by a filter is not mistaken for another raw input on later edits; the complete nested value still has depth, item and type checks.
On Laravel 13, when an AsArrayObject cast has unsaved changes and you then write another JSON path, Sanigen filters the changed values once. Siblings already stored and filtered are left unchanged. Invalid UTF-8 in values or keys, non-finite numbers, unsupported values and excessive nesting in a pending cast are rejected before Laravel's JSON encoding can replace them. A rejected JSON-path write restores the raw in-memory attribute so correcting the pending value and retrying works. Laravel 12 has different native behavior: its JSON-path write discards pending AsArrayObject mutations, which Sanigen preserves for compatibility.
One pipeline for an entire attribute
use Illuminate\Database\Eloquent\Model; use IvanBaric\Sanigen\Traits\Sanigen; class Preference extends Model { use Sanigen; protected array $sanitize = ['settings' => 'text']; protected $casts = ['settings' => 'array']; } $preference = new Preference; $preference->settings = [ 'heading' => ' <b>Hello</b> world ', 'cards' => [['label' => ' <em>First</em> ']], 'count' => 3, 'ratio' => 1.5, 'enabled' => true, 'optional' => null, ];
The resulting value is:
[
'heading' => 'Hello world',
'cards' => [['label' => 'First']],
'count' => 3, // Still an integer
'ratio' => 1.5, // Still a float
'enabled' => true, // Still a boolean
'optional' => null,
]
Different pipelines inside the same JSON value
Use dot notation to select a field and * to match one existing array key, including numeric or associative keys:
// In a model with settings cast to array: protected array $sanitize = [ 'settings.heading' => 'text', 'settings.contacts.*.email' => 'email', 'settings.contacts.*.name' => 'text', 'settings.groups.*.members.*.name' => 'text', 'settings.price' => 'decimal', ];
For example:
$model->settings = [ 'heading' => ' <b>Contact us</b> ', 'contacts' => [ ['name' => ' <em>Ana</em> ', 'email' => ' ANA@EXAMPLE.COM '], ], 'price' => '-12,50 €', 'count' => 2, 'unmatched' => '<b>Left unchanged</b>', ];
After assignment:
[
'heading' => 'Contact us',
'contacts' => [['name' => 'Ana', 'email' => 'ana@example.com']],
'price' => '-12.50',
'count' => 2,
'unmatched' => '<b>Left unchanged</b>',
]
Missing paths and wildcards without matches do nothing; they do not create new keys. A rule ending at an array becomes the default for strings in that subtree. Unmatched fields retain their values, but the entire root still obeys structural and input-size limits.
Root defaults and specific overrides
protected array $sanitize = [ 'settings' => 'text', 'settings.email' => 'email', 'settings.contacts.*' => 'text', 'settings.contacts.*.email' => 'email', ];
Each string uses exactly one pipeline. A longer matching path wins; at equal length, more literal segments win. A child rule overrides the root default rather than running both. Declaration order does not affect the result. Equally specific conflicting pipelines throw an exception instead of choosing silently.
Malformed paths such as settings..email, partial wildcards such as contacts*, wildcard roots such as *.email, and invalid UTF-8 in JSON keys are rejected. Objects, resources and non-finite numbers (NaN, positive or negative infinity) are unsupported. Processing builds a copy: a failure does not partially write the attribute.
Existing recursive: rules
// Current syntax: protected array $sanitize = ['settings' => 'text']; // Legacy equivalent: protected array $sanitize = ['settings' => 'recursive:text'];
The prefix remains accepted for applications upgrading from 1.8.0, but is deprecated and may be removed in a future major release. New code needs no prefix. An empty recursive: expression is invalid.
Spatie Translatable
Use Sanigen's HasSanitizedTranslations trait. It combines Sanigen with Spatie's HasTranslations and resolves their overlapping assignment methods, so you do not need to import both separately.
composer require spatie/laravel-translatable
use Illuminate\Database\Eloquent\Model; use IvanBaric\Sanigen\Traits\HasSanitizedTranslations; class Page extends Model { use HasSanitizedTranslations; public array $translatable = ['title', 'content']; protected array $sanitize = [ 'title' => 'text', 'content' => 'safe_html', ]; }
Use JSON columns for the translated fields, as required by Spatie. Assign all locales together or use Spatie's translation methods:
$page = new Page; $page->title = [ 'hr' => ' <strong>Hrvatski naslov</strong> ', 'en' => ' <strong>English title</strong> ', ]; $page->setTranslation('title', 'de', ' <b>Deutscher Titel</b> '); $page->setTranslations('content', [ 'hr' => '<p>Pozdrav <script>alert(1)</script></p>', 'en' => '<p>Hello <script>alert(1)</script></p>', ]); $page->getTranslations('title'); // ['hr' => 'Hrvatski naslov', 'en' => 'English title', 'de' => 'Deutscher Titel'] $page->getTranslation('content', 'en'); // '<p>Hello </p>'
Every locale is sanitized, including writes through setTranslation() and setTranslations(). Spatie's JSON storage and normal translation reads are preserved. Non-translatable fields still use Sanigen's ordinary model behavior; $generate is available on the same model.
Partial translation writes check structured limits against the complete future JSON root, not just the new locale. Only incoming locales run through filters; existing locales and sibling JSON fields are preserved. For a nested Spatie key such as settings->label, declare the sanitizer path as settings.label and cast settings to array.
setTranslations() prepares the entire incoming batch before writing. If filtering or a native setter fails, the model's Eloquent attributes are restored. This is in-memory batch atomicity, not a database transaction or rollback of external side effects from your event listeners. An empty map clears the selected translated field, including a nested map.
Custom generators
A generator creates a value for one model field. Implement GeneratorContract, register a rule name and select that rule in $generate.
Create the class
php artisan make:generator CouponCode
Edit app/Generators/CouponCodeGenerator.php:
namespace App\Generators; use Illuminate\Support\Str; use IvanBaric\Sanigen\Generators\Contracts\GeneratorContract; class CouponCodeGenerator implements GeneratorContract { public function generate(string $field, object $model): mixed { return 'SALE-'.strtoupper(Str::random(8)); } }
$field is the destination attribute name; $model is the model being created. A generator may return a string, number, date or another value compatible with that attribute's cast.
Register the rule
In a service provider's boot() method, for example AppServiceProvider:
Keep the provider's existing logic; add the registration call rather than replacing its other responsibilities.
use App\Generators\CouponCodeGenerator; use Illuminate\Support\ServiceProvider; use IvanBaric\Sanigen\Registries\GeneratorRegistry; class AppServiceProvider extends ServiceProvider { public function boot(): void { GeneratorRegistry::register('coupon_code', CouponCodeGenerator::class); } }
Use it on a model
use Illuminate\Database\Eloquent\Model; use IvanBaric\Sanigen\Traits\Sanigen; class Coupon extends Model { use Sanigen; protected $fillable = ['description']; protected array $generate = ['code' => 'coupon_code']; } $coupon = Coupon::create(['description' => 'Welcome discount']); $coupon->code; // 'SALE-' followed by 8 random uppercase characters
Create the code column yourself. This custom example generates a random code but does not check for collisions; add a unique index and handle conflicts if the code must be unique.
Accept a parameter
The registry passes the text after : to the generator's constructor. To support coupon_code:12, add this constructor to the class above and use $this->length instead of 8 in Str::random():
Custom generators must be instantiable and have a public constructor that needs no more than one argument. A rule with :value requires a constructor that accepts text (or a compatible scalar/union type); Sanigen rejects an unused or object-only parameter instead of failing later. An optional object-only constructor argument is fine when the rule omits :value. For a bool constructor parameter, use true/false, 1/0, yes/no or on/off. For an int parameter, use a whole decimal number within PHP's integer range; fractional and exponential spellings are rejected instead of being silently converted. A float parameter must be numeric and finite; values that overflow to infinity are rejected. Validate other parameter values in your constructor.
public function __construct(?string $length = null) { $this->length = $length === null ? 8 : (int) $length; if ($this->length < 1 || $this->length > 64) { throw new \InvalidArgumentException('Coupon code length must be between 1 and 64.'); } } private int $length;
Then declare protected array $generate = ['code' => 'coupon_code:12'];. Registering a new name uses the same API; avoid overriding a built-in name accidentally.
Custom filters
A custom sanitizer handles one string, not an entire nested structure. The structured engine applies it to matching strings automatically. Custom sanitizers must return valid UTF-8. Sanigen checks each output before another filter or nested alias can change it and rejects malformed bytes without partially writing the model.
php artisan make:sanitizer Username
Edit app/Sanitizers/UsernameSanitizer.php:
namespace App\Sanitizers; use IvanBaric\Sanigen\Sanitizers\Contracts\Sanitizer; class UsernameSanitizer implements Sanitizer { public function apply(string $value): string { return strtolower(trim($value)); } }
Register it in your service provider's boot() method:
use IvanBaric\Sanigen\Registries\SanitizerRegistry; SanitizerRegistry::register('username', \App\Sanitizers\UsernameSanitizer::class);
Use it directly, combine it with other filters or put it in a config alias:
protected array $sanitize = [ 'username' => 'username', 'settings.members.*.username' => 'strip_html|username', ];
Both scaffolding commands accept subdirectories, --path, --namespace and --force. The last option overwrites an existing class; use it deliberately.
Rule sources and lifecycle
Rules are merged per field: config defaults → PHP attributes → model properties. Class attributes are inherited from parent models, with the nearest child's attribute winning for the same field. The last declaration of the same field wins; fields only present in a lower-priority source remain active. An empty property array does not remove config defaults. To disable one inherited or default generator, explicitly set that field to an empty rule in the model property, for example protected array $generate = ['slug' => ''];; other generators remain active.
The optional class-attribute syntax is:
use Illuminate\Database\Eloquent\Model; use IvanBaric\Sanigen\Attributes\Generate; use IvanBaric\Sanigen\Attributes\Sanitize; use IvanBaric\Sanigen\Traits\Sanigen; #[Sanitize(['name' => 'text', 'email' => 'email'])] #[Generate(['slug' => 'slugify:name', 'uuid' => 'uuid'])] class Post extends Model { use Sanigen; protected $fillable = ['name', 'email']; }
Sanitization happens on attribute assignment, including fill() and inputs to create(), not only on save. Generators run on creating; they do not run when you merely construct a model. Assigned values are cleaned again on updates. Sanigen does not infer rules from column types.
sanitizeAttributes() reapplies the model's current rules to values already loaded into the instance and returns whether anything changed. It processes each top-level attribute once and handles all Spatie locales with the integration trait.
Existing records
Changing rules does not rewrite stored data. Back up the database, test on staging, then preview changes before running the write operation:
php artisan sanigen:resanitize "App\Models\Post" --tenant=42 --dry-run php artisan sanigen:resanitize "App\Models\Post" --tenant=42 --chunk=200
A model with the configured tenant column (team_id by default) requires an explicit tenant selection:
php artisan sanigen:resanitize "App\Models\Post" --tenant=42 --dry-run php artisan sanigen:resanitize "App\Models\Post" --tenant=42 --chunk=200
--chunk must be a positive integer no larger than the configured maximum; malformed values fail before processing. Processing all rows requires --all-tenants --force, even for a dry run, an unscoped model, or when required tenant selection is disabled. This is a change from earlier unscoped full scans: a missing configured tenant column can also be a typo on a model with an arbitrary custom owner column. Do not combine --tenant and --all-tenants. --tenant requires the configured column; --all-tenants --force explicitly authorizes processing all rows visible to the model query when that column is absent. With --tenant, the command rechecks ownership before counting or saving a chunk and rolls back a write chunk if a saved row changes tenant. Dry-run counts remain a point-in-time preview when other processes are changing records. The command saves changed records with saveQuietly(), skips regular save events, checks that the model has no unsaved changes, verifies that saved rows still exist on the model's connection, and returns a failure exit status when processing fails. A mutator that restores the original stored value is not counted as an update. Arrange any required cache invalidation separately. See the maintenance details.
For a genuinely unscoped model, review the population and make the full-scan choice explicit:
php artisan sanigen:resanitize "App\Models\Post" --all-tenants --force --dry-run php artisan sanigen:resanitize "App\Models\Post" --all-tenants --force --chunk=200
--all-tenants removes the known Corexis tenant scope, not every global scope on your model. Other scopes can still hide rows or entire tenants; --tenant is also intersected with them and may match zero rows. Compare the dry-run count with your expected population. If you truly need records hidden by other scopes, use an explicitly reviewed migration or maintenance command rather than disabling every scope automatically.
Safety and limits
Sanigen is not a substitute for validation, authorization, tenant scoping or database constraints. Raw SQL, Query Builder updates and bulk writes that bypass model assignment are not sanitized; writes that bypass creation events skip generators.
Eloquent push() does run Sanigen rules on loaded related models, but it is not atomic: if saving a child returns false, an earlier parent save remains in the database. For an all-or-nothing operation, use a database transaction and throw when push() returns false (merely returning false from the transaction callback still commits):
use Illuminate\Support\Facades\DB; use RuntimeException; DB::transaction(function () use ($post): void { if (! $post->push()) { throw new RuntimeException('A related model could not be saved.'); } });
After a rollback, do not retry push() blindly on the same in-memory model. Eloquent may already consider the attempted parent change synchronized even though the database reverted it. Refresh the model, reapply the desired changes and rebuild any unsaved loaded relations before retrying.
Keep ordinary text escaped in Blade:
{{ $post->name }}
Use raw output only for a field deliberately cleaned with safe_html, including any legacy/imported data:
{!! $post->content !!}
safe_html uses a parser-based safe-element allowlist, configured link schemes, HTTPS normalization and link rel protection. Active link schemes (data, javascript, vbscript, file and blob) remain blocked even if mistakenly added to safe_html_allowed_schemes. The allowed_html_tags option applies to the compatibility filters strip_tags and strip_scripts, not to safe_html.
strip_html returns plain text characters after removing markup, not HTML entity spellings: <p>A & B</p> becomes A & B. Render plain text with normal Blade escaping ({{ $value }}), never as raw HTML. Already plain input without < is preserved, including literal entity notation. Legacy strip_scripts also preserves plain entity notation such as A & B when it hides no markup or script pattern; encoded markup is decoded and cleaned. It checks the original byte length before preprocessing. Use safe_html for intentionally unescaped rich HTML.
Sanigen's input and output are UTF-8. Keep encoding at its default UTF-8 for strip_scripts (utf8 is accepted and normalized). Other charsets are rejected even with failure_mode: original or null: decoding entities to another charset could otherwise silently erase visible text or return unfiltered markup.
One content-fidelity limit of strip_html: <p><script>example</script></p> becomes the plain-text value <script>example</script>. Blade escapes it safely, but if that value is assigned again or passed through sanigen:resanitize, it is indistinguishable from actual script markup and can be removed. If literal tag-like text must survive repeated cleaning, use a safe_html rich-text field and render that sanitized field as shown above. Do not use raw Blade output for a strip_html field.
Sanitizer failures throw by default. failure_mode also supports null and original; the latter can preserve unsafe input and is only for controlled compatibility migrations. Missing sanitizers have their own missing_sanitizer setting; keep its default 'throw' in production.
Every structured traversal enforces max_nested_depth, max_nested_items, max_scalar_input_length and applicable HTML limits. Structural errors are not silently ignored. See the configuration reference for every option and default.
Further documentation
- Schema, shared defaults and slug configuration
- Complete configuration and command reference
- Release history
The filter catalog, generator catalog, recursion, Spatie integration and extension examples above are the main public API guide. The linked documents add operational detail rather than replacing those examples.
Development
From a standalone checkout:
composer install
composer test
vendor/bin/pint --test
vendor/bin/phpstan analyse
composer audit
License
MIT. See LICENSE.md.