makinacorpus/db-tools-bundle
Symfony bundle for database maintenance: create and restore backups, anonymize sensitive data, and run SQL commands through configurable, Doctrine-friendly console tooling for safer development and production workflows.
Install and enable:
composer require --dev makinacorpus/db-tools-bundle
It's a Symfony bundle (auto-enabled via Flex). If not using Flex, register it in config/bundles.php. It works on top of Doctrine DBAL, so it inherits your existing DATABASE_URL — you generally don't duplicate connection credentials.
Version note: 2.2.0 added Symfony 8 support, so the bundle now tracks the current Symfony line as well as older ones — but confirm the constraint in composer.json matches the framework version you're actually running before bumping, and read UPGRADE.md for any intervening changes.
Publish a minimal config at config/packages/db_tools.yaml:
db_tools:
backup:
storages:
local:
dsn: 'file://%kernel.project_dir%/var/backups'
anonymization:
default:
customer:
email: email
firstname: firstName
lastname: lastName
Orient yourself in five minutes:
php bin/console list db-tools # the real source of truth for options
php bin/console db-tools:backup --help
php bin/console db-tools:anonymize --help
Then read, in order: src/Command/ (what each command actually accepts), src/Anonymizer/Core/ (the built-in anonymizer catalogue and their exact names), and src/Backup/ (which native binary your DSN maps to). The docs/ folder and the bundle's own test fixtures are the best source of valid YAML shapes — the config tree is nested enough that guessing keys wastes time.
First use case — the one that pays for itself immediately: run db-tools:backup before a risky migration or a doctrine:schema:update, and db-tools:restore when it goes wrong. That's a five-line config and a single command in your deploy/ops runbook, and it removes the "we need a DBA to roll back" conversation entirely.
Second use case — the reason most teams install it: pull a production dump, restore it locally, anonymize it, and hand developers a realistic database that isn't a GDPR incident.
php bin/console db-tools:restore # interactive picker over your storage
php bin/console db-tools:anonymize --target=dev
Environment-scoped anonymization targets. The default block is the base ruleset; targets: layers overrides per environment or per audience. This is the cleanest way to express "testers get fake names but real product data, CI gets everything fake":
db_tools:
anonymization:
default:
customer:
email: email
firstname: firstName
lastname: lastName
order:
note: text
targets:
dev:
customer:
lastname: lastName
ci:
customer:
firstname: firstName
lastname: lastName
email: email
order:
note: null # skip this column for CI
Wire the target to your workflow rather than the environment: a make db:dev alias that runs restore → anonymize → migrate → fixtures means nobody has to remember the incantation.
Safety net around migrations. Standard pattern in deploy.sh / Makefile:
php bin/console db-tools:backup --storage=local
php bin/console doctrine:migrations:migrate --no-interaction
Keep a local storage for developer machines and an object-storage DSN (S3-compatible) for pre-deploy snapshots, with a short retention policy. The bundle stores dumps as plain files, so retention/pruning is your job — usually a cron or lifecycle rule on the bucket.
Custom anonymizers for domain data. Faker's defaults will not know what a customer_reference or a free-text internal_notes column means. Implement the anonymizer interface and tag it:
namespace App\DbTools;
use Makinacorpus\DbToolsBundle\Anonymizer\AnonymizerInterface;
class CustomerReferenceAnonymizer implements AnonymizerInterface
{
public function getUniqueName(): string
{
return 'customer_reference';
}
public function anonymize(string $value): string
{
// Deterministic: same input -> same output keeps joins and uniqueness intact
return 'CUST-' . substr(hash('sha256', $value . 'salt'), 0, 10);
}
}
services:
App\DbTools\CustomerReferenceAnonymizer:
tags: ['db_tools.anonymizer']
Recent versions also accept a PHP attribute in place of the tag — check src/Anonymizer/ for what your installed version supports. The key design rule: prefer deterministic (hash-based) anonymizers for anything that's a foreign key, a unique constraint, or referenced across tables, and random ones only for genuinely free-standing columns. This single habit prevents 90% of constraint-violation errors during anonymization.
Preview before you commit. Anonymization issues UPDATE statements against a live database. Run the dump/preview command (see bin/console list db-tools) to inspect the generated SQL, or point it at a throwaway container first. Treat "anonymize" as a destructive operation in your scripts: gate it behind an explicit flag or a guard that refuses to run when the DSN host isn't localhost/127.0.0.1.
CI usage. A job that restores a recent anonymized snapshot and runs the suite against it catches far more than fixtures do. Pattern: store the anonymized dump as a CI artifact or S3 object, restore it into the service container, then run PHPUnit. This avoids shipping a separate anonymization step into every pipeline run.
Multiple connections. If your app uses several Doctrine connections, check --help for the connection selector and make sure your backup DSN resolves to the one you think it does — silently backing up default when you meant reporting is a classic incident.
Native binaries are a hard dependency. The bundle shells out to mysqldump, pg_dump, or sqlite3 via Symfony Process. It is not a pure-PHP dumper. Consequences: the CLI tool must exist in the container/CI image, and a client older than the server fails hard on PostgreSQL (server version mismatch). Pin the client version alongside your server major version in Docker images. "Command works locally, fails in CI" is almost always this.
Backups are raw — anonymity is not automatic. db-tools:backup dumps exactly what's in the database. If you copy a production backup to a shared bucket or a developer laptop, you've moved PII. The safe order is: dump → restore into an isolated database → anonymize → snapshot that. Consider encrypting the storage or restricting it to a security-reviewed bucket.
Anonymization mutates in place. There is no dry-run database. Always operate on a restored copy, never on the source, and assume partial failure is possible on large tables — if the process dies mid-way you have a half-anonymized database that looks plausible. Re-restore and re-run rather than resuming.
Constraint collisions. Random emails will eventually violate UNIQUE; random UUIDs will orphan foreign keys. Symptom: the command aborts with a constraint error partway through. Fix by switching that column to a deterministic anonymizer, or by anonymizing parent and child columns with the same mapping. Some engines also need FOREIGN_KEY_CHECKS/session_replication_role disabled during bulk updates — look at how the bundle batches its UPDATEs before adding your own hacks.
Type mismatches. Anonymizers generally receive and return strings. json, array, and bytea/blob columns need the dedicated anonymizer (or null to skip) — feeding a JSON column to a generic text anonymizer corrupts it, and you'll discover it weeks later when a developer can't reproduce a bug.
Silent no-ops from config typos. A table name that doesn't match (case sensitivity on MySQL, schema prefix on PostgreSQL) or an anonymizer name that doesn't exist tends to be skipped rather than blown up loudly, depending on version. After changing config, verify with the preview/dump command that the expected UPDATEs actually appear — don't trust that green output means your column was touched.
Storage DSN quirks. file:// DSNs need the directory to exist and be writable by the PHP/CLI user; trailing-slash differences and %kernel.project_dir% resolution are frequent papercuts. For S3, credentials come from the environment — make sure the CLI context has them, not just php-fpm.
Restore is interactive by default. Good for humans, bad for scripts. Check --help for the non-interactive flag and use it in automation; otherwise a cron job will hang forever waiting on a prompt.
Symfony 8 support landed in 2.2.0 — but that's the only change. It's a compatibility release, not a behavioural one: no new commands, no config changes, no deprecations relevant to day-to-day use. Treat it as routine housekeeping — safe to adopt, but nothing in your runbook or config needs revisiting for it. If you're still on an older Symfony line, there's no functional reason to chase this version.
Low-adoption risk assessment. With ~234 stars and effectively no dependents, this is a small-community bundle. That's not disqualifying — it solves a narrow, well-defined problem — but budget for reading source rather than finding Stack Overflow answers. Pin the version in composer.json, keep it in require-dev unless production backups depend on it, and read UPGRADE.md/CHANGELOG before bumping. Because it's a thin orchestration layer over native tools and Doctrine, the blast radius of abandoning it later is small: the config is declarative and the fallback (mysqldump + a few hand-written UPDATEs) is trivial to reproduce.
Extension points worth knowing. Three seams cover most non-standard needs: a custom anonymizer (the db_tools.anonymizer tag), a custom storage backend implementing the storage interface (for an internal artifact service), and overriding the command services in your own container if you need different default flags. Anything beyond that, and you're better off writing a small wrapper command than forking the bundle.
How can I help you explore Laravel packages today?