endroid/data-sanitize-demo-bundle
A lightweight Symfony demo bundle from Endroid that illustrates data sanitization in practice. It serves as a reference for cleaning and validating incoming data before it reaches your application, helping developers explore sanitization patterns and adapt them to their own PHP projects.
Read this first: it is a demo bundle, not a dependency. The repository is a Symfony bundle with a stub README (title only), 1 star, 0 dependents, and no versioning or test signal. In a Laravel codebase you will never composer require it in production — you read the source and port the pattern. Frame your first hour around extraction, not installation.
If you genuinely want to run it to observe behavior, keep it strictly dev-scoped and in a throwaway Symfony sandbox — mixing a Symfony bundle into a Laravel app means manually registering a bundle class that Laravel's kernel does not know about, which is wasted effort:
composer require --dev endroid/data-sanitize-demo-bundle
In a Symfony sandbox you would then add it to config/bundles.php:
Endroid\DataSanitizeDemoBundle\DataSanitizeDemoBundle::class => ['dev' => true],
Then bin/console list to find the demo command. In a Laravel project, skip this entirely and go straight to reading code.
composer.json — PHP/Symfony constraints. This tells you instantly whether the demo targets PHP 5.x/7.x and Symfony 2/3/4 (likely), which means it will never coexist with Laravel 10/11 on PHP 8.1+.src/Command/ — the demo entry points; they show the intended call sequence (read → sanitize → dedupe → report/persist).src/Service/ or src/Sanitizer/ — the actual cleaning and duplicate-detection logic. This is the only part worth your time.src/DependencyInjection/ and config/services.yaml — how sanitizers are tagged and composed. This is the pattern you will replicate in Laravel's container.tests/ — if it exists at all; treat its absence as a given.Ignore README.md; it contains nothing beyond the title.
The realistic day-to-day scenario this informs: a messy import that must be cleaned and de-duplicated before it hits your tables. CSV of customer records, a legacy table dump, or a webhook feed with inconsistent casing, padded whitespace, mixed email formats, and repeated rows. The demo shows the shape of that pipeline; you will build it with Laravel primitives:
php artisan make:command ImportCleanCustomers
with the flow: stream/chunk input → run sanitizer chain → compute dedupe keys → dry-run report → insert with upsert() behind a unique index.
The bundle's core teaching is a set of small, single-responsibility sanitizer services composed via a tagged iterator. In Symfony that is !tagged_iterator app.sanitizer; in Laravel you get the same result with container tagging and a lazy collection:
// AppServiceProvider::register()
$this->app->singleton(SanitizerChain::class, function ($app) {
return new SanitizerChain([
new TrimWhitespace(),
new LowercaseEmail(),
new StripControlChars(),
new NormalizePhone('NL'),
new CollapseWhitespace(),
]);
});
Or, if you want config-driven discovery, tag them and resolve:
$this->app->tag([
TrimWhitespace::class,
LowercaseEmail::class,
StripControlChars::class,
], 'app.sanitizer');
$this->app->singleton(SanitizerChain::class, fn ($app) =>
new SanitizerChain(iterator_to_array($app->tagged('app.sanitizer')))
);
Each sanitizer is one function: sanitize(mixed $value): mixed. Adding a rule means adding a class — that is the extensibility the demo is demonstrating, and it maps cleanly onto Laravel.
Order matters (strip control chars before collapsing whitespace; trim after concatenation). If your rules have a strict sequence, prefer Laravel's Pipeline over an unordered tagged iterator:
$clean = app(Pipeline::class)
->send($row)
->through([
StripControlChars::class,
TrimWhitespace::class,
LowercaseEmail::class,
])
->thenReturn();
Keep per-field rules in a config array so the order is reviewable in one place rather than scattered across service definitions.
The demo will show naive comparison. In production, pick deliberately:
| Strategy | When | Laravel implementation |
|---|---|---|
| Exact match | Stable business key exists | unique index + insertOrIgnore() |
| Normalized match | Key varies by casing/whitespace/format | Compute a dedupe_hash column, index it |
| Business-key match | Composite key (email + postcode) | Composite unique index |
| Blocking + fuzzy | No reliable key | Bucket on a cheap key, then similar_text/levenshtein inside the bucket |
The scalable pattern is blocking: never compare all rows against all rows. Bucket on a normalized field (e.g. substr(soundex(last_name),1,4) or normalized domain), then only run fuzzy comparison within a bucket. This is what the demo almost certainly omits and what will save you at volume.
// Normalize + hash for the blocking key
$row['dedupe_hash'] = hash('xxh128', implode('|', [
Str::lower(trim($row['email'])),
preg_replace('/\D/', '', $row['phone'] ?? ''),
]));
Then upsert() on dedupe_hash for idempotent re-runs.
Do not centralize everything in one middleware. Split by concern:
Validator rules (email, string, max:). Reject early.spatie/laravel-data DTO with a from() constructor that sanitizes.upsert/insertOrIgnore, never an application-level exists() check (racy).class CustomerData extends Data
{
public function __construct(
#[Required] public string $email,
public string $name,
) {}
public static function fromArray(array $payload): self
{
return new self(
email: Str::lower(trim($payload['email'])),
name: Str::squish($payload['name']),
);
}
}
Adapt the bundle's console command into an Artisan command with the affordances a demo will not have:
--dry-run that writes a diff report and mutates nothing--limit= and chunkById(500)sanitize_audit table or log channel--source= argument instead of hardcoded fixturesFor large volumes, dispatch SanitizeBatchMessage per chunk on a queue rather than looping in-process — this is the single biggest difference between demo and production.
If you are removing duplicates, you need precision/recall on a labeled sample before deleting anything. Build a tiny fixture set of ~50 hand-labelled pairs, run your matcher, and measure. The demo bundle has no such harness; add it before you write merge logic, because merge rules are where silent data loss lives.
If the rules prove out, wrap only the proven logic in an internal Composer package exposing Laravel-friendly config (config/sanitize.php publishable) and a Sanitize facade. Do not fork the demo bundle wholesale — fork the two or three classes you actually validated.
Do not install it into a Laravel app. Laravel's kernel does not register Symfony bundles. Any "integration" is custom glue with zero upstream support. --dev scoping is the only defensible placement, and even then a sandbox is better.
Version constraints will likely block you anyway. Demo bundles from that era typically pin PHP 5.x/7.x and Symfony 2/3/4. Check composer.json before a spike — even if Composer resolves, you can pull unsupported Symfony components transitively into your lockfile.
Demo fixtures can be loaded accidentally. If the bundle ships sample data or seeders, running its command against a real database is the worst-case failure. Always run first against a copy, never against production.
Order-of-operations bugs in sanitizer chains. Unordered tagged iterators are non-deterministic in effect. Unicode normalization (Normalizer::normalize(..., Form::NFKC)) must happen before comparison, not after; case folding must precede hashing. Write one test per rule and one integration test asserting the final shape of a known-dirty row.
Aggressive normalization causes collisions. Lowercasing, stripping punctuation, and collapsing whitespace can map genuinely distinct records onto the same key. Never put a unique index on a normalized column before you have measured collision rate on real data — add it as a non-unique index first and query for duplicates.
Silent data loss is the top risk of dedupe. Never hard-delete on a fuzzy match. Insert into a merge_candidates table with a confidence score, and require either a threshold plus audit log or a human review step. Keep the original row; soft-delete or link via merged_into_id.
Non-idempotent retries. Queue workers retry. If your sanitize job inserts, a retry duplicates rows. Guard with a unique constraint and upsert(), or a processed_at marker on the source, so a second pass is a no-op.
Encoding issues surface late. Invalid UTF-8 from CSV imports will silently truncate or produce replacement characters. Validate with mb_check_encoding($v, 'UTF-8') at ingest and convert from the source encoding explicitly; do not let MySQL's utf8 (3-byte) column quietly mangle emoji.
Memory and timeouts at scale. ->get() on the import table will OOM. chunkById() (not chunk() — the latter is unstable when you mutate or insert while iterating). For fuzzy matching, precompute a blocking key so you never do an O(n²) pass.
Database constraints are the real dedupe mechanism. Application-level if (Customer::where('email', $email)->exists()) is a race condition under concurrency. Rely on the unique index and catch QueryException / use insertOrIgnore().
Rollback plan is not optional. Before any destructive cleanup: snapshot the table, or take a full column-level copy of affected fields into an audit table, and write the reverse migration. Test the rollback on the copy.
Extension points worth copying from src/DependencyInjection/: the service tags and the compiler-pass-style composition. That is the transferable lesson — how to make sanitizers pluggable without touching the orchestrator. Everything else in the bundle is illustrative sample code.
Don't trust the README for a config reference. It is a title. There is no documented install path, no configuration schema, no upgrade policy. Anything you learn must come from reading src/.
Maintenance ownership starts day one. 1 star, 0 dependents, no tests, no semver. If any code is copied (MIT permits it), assign an owner, add tests, and put it through the same security review as first-party code. Prefer maintained, framework-agnostic libraries for normalization/similarity and keep the dedupe policy — which is genuinely yours — in-house.
How can I help you explore Laravel packages today?