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
README
Sanigen provides declarative sanitization and attribute generators for Laravel Eloquent models.
Quick Start
composer require ivanbaric/sanigen php artisan vendor:publish --provider="IvanBaric\Sanigen\SanigenServiceProvider" --tag="config"
use Illuminate\Database\Eloquent\Model; use IvanBaric\Sanigen\Traits\Sanigen; class Post extends Model { use Sanigen; protected array $sanitize = [ 'title' => 'text', 'description' => 'plain_text', 'content' => 'safe_html', 'email' => 'email', 'website' => 'url', 'price' => 'decimal', ]; }
Sanitization runs when an attribute is assigned through the Eloquent model. All sanitizer rules automatically recurse through arrays while preserving integer, float, boolean, null values, keys, and structure.
Sanitizer classes still receive and return one string. Sanigen's structured engine owns recursion, path matching, type preservation, limits, and conflict handling.
Structured Values
Homogeneous JSON
A root rule is the default for every string in that attribute:
protected array $sanitize = [ 'translations' => 'text', ]; $model->translations = [ 'hr' => '<b>Naslov</b>', 'en' => '<b>Title</b>', 'published' => true, 'revision' => 3, ];
The two strings are cleaned while true remains a boolean and 3 remains an integer. Arrays may be nested to any configured depth.
Heterogeneous JSON
Use dot notation when different fields need different pipelines:
protected array $sanitize = [ 'settings.title' => 'text', 'settings.email' => 'email', 'settings.price' => 'decimal', ];
Only existing matching values are sanitized. Missing paths are a no-op, keys are never created, and unrelated fields remain unchanged. If a path ends at an array, its pipeline becomes the default for every string in that subtree.
Wildcards
* matches one existing array key, including numeric and associative keys:
protected array $sanitize = [ 'settings.contacts.*.name' => 'text', 'settings.contacts.*.email' => 'email', 'settings.groups.*.members.*.name' => 'text', ];
Multiple wildcard levels are supported. Empty arrays and wildcards without matches are no-ops.
Invalid paths fail before the value is written. Empty segments, partial wildcards such as cont*cts, and a wildcard root such as *.email are rejected.
Rule Precedence
Each string leaf uses exactly one pipeline:
- The longer matching path wins.
- At equal length, the path with more literal segments wins.
- A root rule is the default for its entire structure.
- A specific child rule overrides that default.
- Equal-specificity matches with different pipelines throw an exception.
Declaration order never changes the result:
protected array $sanitize = [ 'settings' => 'text', 'settings.email' => 'email', 'settings.contacts.*' => 'text', 'settings.contacts.*.email' => 'email', ];
Sanigen groups these rules under settings and traverses that root value once.
Spatie Translatable
Install Spatie's package and use Sanigen's integration trait instead of importing two conflicting setAttribute traits:
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', ]; } $page->title = [ 'hr' => '<strong>Hrvatski naslov</strong>', 'en' => '<strong>English title</strong>', ]; $page->content = [ 'hr' => '<p>Hrvatski <script>alert(1)</script></p>', 'en' => '<p>English <script>alert(1)</script></p>', ];
Every locale is sanitized automatically. The bridge preserves Spatie's normal JSON storage and translation reads while ensuring Sanigen receives the complete value before it is stored.
Standard Aliases
The shipped aliases are config-driven and may be replaced in config/sanigen.php:
'aliases' => [ 'text' => 'unicode|strip_html|strip_emoji|strip_newlines|trim|squish', 'plain_text' => 'unicode|strip_html|strip_emoji|normalize_newlines|trim', 'title' => 'unicode|strip_html|strip_emoji|strip_newlines|trim|squish|lower|ucfirst', 'ascii' => 'unicode|strip_html|strip_emoji|strip_newlines|trim|squish|ascii|trim', 'safe_html' => 'unicode|safe_html', 'email' => 'trim|lower|email', 'url' => 'trim|strip_newlines|url', 'slug' => 'trim|lower|slug', 'decimal' => 'trim|decimal', 'phone' => 'trim|phone_clean', ],
textproduces one line of ordinary text and normalizes whitespace.plain_textkeeps intentional line breaks and normalizes\r\nand\rto\n.safe_htmlretains allowed rich HTML after parser-based sanitization.
The unicode primitive performs only Unicode NFC normalization. It does not transliterate Croatian letters (č ć ž š đ Č Ć Ž Š Đ), guess legacy encodings, or repair mojibake. Invalid Unicode fails closed.
The normalize_newlines primitive only converts Windows and old Mac line endings to \n.
Decimal Values
decimal normalizes textual user input:
12,50 € -> 12.50
1.234,56 € -> 1234.56
1,234.56 USD -> 1234.56
-12,50 € -> -12.50
Inside arrays, integers remain integers, floats remain floats, booleans remain booleans, and null remains null.
Sanigen normalizes input. Laravel validation decides whether a value is allowed. An Eloquent cast decides how it is stored and presented. decimal is not a validation rule.
Deprecated recursive: Prefix
Since every rule now recurses automatically, these declarations are equivalent:
'settings' => 'text', 'settings' => 'recursive:text',
recursive: remains accepted for applications upgrading from 1.8.0, but it is deprecated and may be removed in a future major release. New code should use the ordinary rule. An empty recursive: expression still throws a clear exception. No runtime deprecation warning is emitted.
Rule Sources
Rules can come from three places, in this priority order:
- Model properties (
$sanitize,$generate) - Class-level attributes (
#[Sanitize],#[Generate]) - Config defaults (
sanitize_defaults,generate_defaults)
use IvanBaric\Sanigen\Attributes\Generate; use IvanBaric\Sanigen\Attributes\Sanitize; #[Sanitize(['title' => 'text', 'email' => 'email'])] #[Generate(['slug' => 'slugify:title', 'uuid' => 'uuid'])] class Post extends Model { use Sanigen; }
Sanigen never infers sanitizer rules from database column types.
Built-in Sanitizers
| Sanitizer | Purpose |
|---|---|
unicode |
Normalize valid Unicode to NFC |
normalize_newlines |
Convert \r\n and \r to \n |
trim, lower, upper, ucfirst, squish |
Text transformations |
strip_newlines, strip_html, strip_tags, strip_emoji |
Plain-text cleanup |
safe_html, strip_scripts |
Parser-based HTML sanitization |
alpha, alnum, alpha_dash, ascii, digits |
Character filtering |
decimal, email, phone_clean, url, slug |
Format normalization |
strip_tags is a compatibility wrapper around PHP's strip_tags() and is not an XSS boundary. Use safe_html for rich HTML that will be rendered unescaped.
Custom Sanitizers and Aliases
Every custom sanitizer handles one string:
namespace App\Sanitizers; use IvanBaric\Sanigen\Sanitizers\Contracts\Sanitizer; final class UsernameSanitizer implements Sanitizer { public function apply(string $value): string { return strtolower(trim($value)); } }
use IvanBaric\Sanigen\Registries\SanitizerRegistry; SanitizerRegistry::register('username', \App\Sanitizers\UsernameSanitizer::class);
Aliases can contain built-in sanitizers, custom sanitizers, or other aliases. Circular aliases fail clearly. An alias may share a name with a base sanitizer, as the shipped safe_html alias does.
php artisan make:sanitizer Username php artisan make:sanitizer Admin/TitleClean --force
Generators
The generator API is unchanged:
protected array $generate = [ 'uuid' => 'uuid:v7', 'slug' => 'slugify:title', 'code' => 'unique_string:10', 'expires_at' => 'carbon:+7 days', 'owner_id' => 'user:id', ];
Built-in generators include uuid, ulid, autoincrement, unique_string, random_string, slugify, carbon, and user.
Custom generators continue to use GeneratorRegistry::register() and GeneratorContract:
php artisan make:generator CouponCode
Database unique indexes remain the final authority for values that must be unique under concurrent requests.
Security Model
Sanigen is one layer in the input lifecycle:
- Laravel validation rejects disallowed input.
- Sanigen cleans and normalizes values assigned through Eloquent.
- Eloquent casts control storage and presentation types.
- Blade escaping protects ordinary output.
Keep Blade escaping enabled for ordinary text:
{{ $post->description }}
Render unescaped content only when it is intentionally sanitized with safe_html:
{!! $post->content !!}
Every root traversal enforces:
max_nested_depthmax_nested_itemsmax_scalar_input_lengthmax_html_input_lengthand sanitizer-specific limits
The item counter is shared across the complete root operation. Scalar length is checked before a sanitizer pipeline runs. Objects and resources fail closed with the root and nested path in the exception, without including the submitted value. Sanitization builds a copy and does not partially write an attribute when processing fails.
Sanitizer failures default to:
'failure_mode' => 'throw',
Supported modes are throw, null, and original. original is a compatibility mode that may preserve unsafe input and should be used only with an explicit migration plan. Missing sanitizers separately support throw, ignore, and log; throw is the default.
Existing Rows
sanitizeAttributes() processes each unique top-level attribute once and returns whether anything changed.
The resanitize command applies current rules to stored models:
php artisan sanigen:resanitize "App\Models\Post" --chunk=200 php artisan sanigen:resanitize "App\Models\Post" --dry-run
This command updates records. Test it on staging and use a backup-aware deployment path.
Limitations
Sanigen runs only when data passes through an Eloquent model or sanitizeAttributes() is called. Raw SQL, Query Builder updates, and bulk operations that bypass model assignment are not sanitized.
Development
composer install
composer test
vendor/bin/pint --test
vendor/bin/phpstan analyse
composer audit
License
MIT. See LICENSE.md.