owenmelbz/forget-db
Forget DB is a Laravel safeguard that blocks accidental database writes. It intercepts INSERT, UPDATE, DELETE and schema queries, letting you run your app in a read-only mode on production or staging while logging anything that would have modified your data.
Install the package
composer require owenmelbz/forget-db
The service provider is auto-discovered on Laravel 5.5+. On older Laravel versions, register OwenMelbz\ForgetDb\ForgetDbServiceProvider in config/app.php.
Publish the config
php artisan vendor:publish --provider="OwenMelbz\ForgetDb\ForgetDbServiceProvider"
This drops a config/forget-db.php file. Open it — this is the single source of truth for what gets anonymized and how.
Define your anonymization map. The config maps table => [column => faker_formatter]:
return [
'users' => [
'name' => 'name',
'email' => 'safeEmail',
'phone' => 'phoneNumber',
'password' => 'password', // see gotchas — prefer hashing
],
'orders' => [
'billing_address' => 'address',
'card_last_four' => 'creditCardNumber',
],
];
The values are Faker formatter method names (e.g. firstName, safeEmail, address). The package calls Faker for each column on each row.
First use case. The classic one: you have a production dump you want to load into staging or a demo environment. Restore the dump locally, point .env at it, then run the anonymization command:
php artisan forget-db
Every configured column is overwritten in place. The schema and row count stay the same, but PII is replaced with believable fake data — so your app still works and your designers still see realistic screens.
Where to look first. Read config/forget-db.php in full before touching it. If your schema has dozens of PII columns, start with the highest-risk tables (users, customers, leads, anything with email, phone, first_name, last_name, address). Run against a throwaway copy first, not production.
In-place anonymization of a restored dump (most common pattern). Script it so it's repeatable. A typical staging refresh pipeline:
# 1. pull prod dump
pg_dump ... | pg_restore ... staging_db
# 2. anonymize
php artisan forget-db
# 3. (optional) re-seed known accounts for testing
php artisan db:seed --class=DemoAccountsSeeder
Wire this into a CI job or a small shell script (bin/refresh-staging.sh). Because the config is committed, every developer gets the same anonymized reality.
Pair with a seeder for known test accounts. Anonymization is random. If QA needs a stable login ([email protected] / password), run a seeder after forget-db that recreates the canonical accounts. Document this ordering — doing it backwards means your fixture accounts get scrambled.
Scheduled auto-scrub for long-lived non-prod environments. If you keep a shared "demo" DB alive and it periodically receives synced data, add a scheduled command:
// app/Console/Kernel.php
$schedule->command('forget-db')->weekly()->sundays()->at('03:00');
This keeps stale PII from lingering on a server that shouldn't be able to hold it (a mild GDPR/tolerance argument).
Config-driven scope control. Exclude tables you don't own (migrations, jobs, telescope entries) simply by not listing them. Keep a comment block in the config enumerating deliberately excluded PII tables so a reviewer can spot gaps — e.g. // audit_logs.ip_address intentionally NOT scrubbed — used by support.
Extend with custom Faker providers. Since the config values are formatter names, you can register your own Faker provider for domain-specific data:
$faker->addProvider(new \App\Faker\UkCompanyProvider($faker));
Then reference 'company_name' => 'ukCompanyName' in config. This is the cleanest extension point for anything not covered by stock Faker (national ID formats, internal codes, realistic-but-fake account numbers).
Use closures / custom logic where the formatter list is too rigid. For columns that need derivation (e.g. email must be [email protected]), either add a custom provider method or run a follow-up DB::table(...)->update(...) in a custom command after forget-db. Keeping bespoke logic in your own command avoids fighting the package.
Anonymize consistently across related tables. If users.id is stable but users.name and orders.customer_name are independent, the two will disagree. Either (a) only anonymize the authoritative table and leave denormalized copies, or (b) write your own post-pass that updates children from a hashed/pseudonymized key. There's no built-in cross-table consistency guarantee — plan for it.
The package is old. Last tagged release is 2020. It was written for Laravel ~5.x–7.x. If you're on Laravel 10/11, test in a throwaway branch before relying on it — service provider signatures, Faker namespaces (Faker\Generator vs the newer FakerPHP\Faker\Generator), and config publishing can all break. If it fails, a two-file custom Artisan command that reads a config array and calls DB::table($t)->update([$c => $faker->{$f}()]) reproduces 90% of the functionality.
Unique constraints bite immediately. safeEmail / email return values that are not unique. If users.email has a unique index, you'll get a SQLSTATE[23000] midway through the run, potentially leaving the table half-anonymized. Options: use $faker->unique()->safeEmail, chunk the update, or drop the unique index during the run and re-add it. Test against a schema with real constraints.
Passwords. Mapping password => 'password' writes a plaintext-looking string (still a "password" faker value), not a hash. Any login using that account will fail — which is often the intent, but be explicit. If you need working logins, use a known hash: Hash::make('password') via a custom provider, or overwrite after the run.
Config caching. Laravel caches config/*.php. After editing forget-db.php in a deployed environment, run php artisan config:clear (or config:cache again) or your changes won't apply. This is the #1 "why isn't my table being scrubbed?" cause.
Table prefixes. If your connection uses a table prefix (db_prefix), the config keys must match what the package sees. Verify by dumping one anonymized row and checking whether the update actually landed.
Irreversible — no dry-run. Assume there is no built-in preview. Back up first (mysqldump/pg_dump of the target DB) or run against a disposable schema, then diff. If you need a dry-run, run against a scratch copy of the DB and check a few rows.
Large tables. Row-by-row updates mean a full-table rewrite and heavy transaction/WAL load. On tables above ~1M rows, run off-peak, or do it in chunks with your own command rather than the single-shot artisan call. Watch lock contention on users if anything else is connected.
JSON / array / encrypted columns. Faker produces scalar strings. Casts like json and array will throw or silently corrupt if the package assigns a string where an array is expected. For those, either skip them in forget-db and handle in a custom post-pass (DB::table(...)->update(['meta' => json_encode([...])])), or add a custom provider. Encrypted columns are worse — you must write via the model with the cast set, not raw SQL, or the app will fail to decrypt.
Foreign keys. Overwriting a key column (not just a payload column) will orphan children or violate FK constraints. Never put an id or FK column in the config. For denormalized copies, see the consistency pattern above.
Debugging. The most useful check is: after running, SELECT the configured columns for a handful of rows and confirm the values changed. If nothing changed, it's almost always a config cache or table-name mismatch, not the package itself. Turn on query logging in a local run (DB::listen(...)) to see exactly which tables it touched.
Where to extend. The config map is the primary surface. Beyond that, the realistic extension points are (1) custom Faker providers, and (2) a wrapper command that calls the package then applies your own logic. Don't fork the package for a small tweak — 140 stars and a 2020 last-release means you're on your own for maintenance; a thin wrapper is cheaper.
Treat it as one layer, not the whole compliance story. It scrubs data in a database you've already restored. It does not help with backups, dumps in S3, logs, or exports. The GDPR-flavoured value is real, but pair it with a scoped staging access policy and a documented inventory of what is and isn't scrubbed.
How can I help you explore Laravel packages today?