laravel-db maintained by un-dev-suisse
Laravel db
Database tools for Laravel on PostgreSQL: copy a production database into a local Docker stack, with the personal data
optionally masked; make, list and stream spatie/laravel-backup archives; restore a backup into production for disaster
recovery.
The work is done by artisan commands, and a host script, vendor/bin/db-sync, pipes them together over ssh and
docker compose from the developer's machine. Anonymization is off by default and is chosen at each copy with
--anonymize.
Requirements: PHP 8.3+, Laravel 12 or 13, PostgreSQL.
pg_dumpandpsqlin the application images (production and local), at the major version of the PostgreSQL server and pinned:pg_dumprefuses a server newer than itself. On Alpine:apk add postgresql18-client.psqlmust be one of the August 2025 minor releases or later (17.6, 16.10, 15.14, 14.19, 13.22, or any 18): the loader starts each load with\restrict, which makespsqlrefuse every meta-command of a dump (\!runs a shell command). An olderpsqlstops the load with an explicit message and changes nothing.- On the developer's machine:
ssh,docker compose, andageto read encrypted backups.pvis optional and shows the progress of a transfer. spatie/laravel-backup^9.2 or ^10 for the backup commands and the--backupmode.league/flysystem-aws-s3-v3for backup disks on S3.- The PHP extension
zlibwheredb:restoreor a gzip dump is read, andzipwheredb:importreads aspatie/laravel-backuparchive.
pg_dump and psql get the connection of the application (host, port, database, username and password, with its url
merged in) through the PGHOST, PGPORT, PGDATABASE, PGUSER and PGPASSWORD environment variables. The sslmode of
the connection is not passed on. The two tools inherit the environment of the container, so PGSSLMODE and the other
PGSSL* variables set there apply to them.
Installation
composer require un-dev-suisse/laravel-db
php artisan vendor:publish --tag=db-config
Do not install it with --dev: the server commands (db:dump, db:backup:list, db:backup:stream, db:restore) run
in production. The service provider is discovered by Laravel, and registers its commands in the console only. The
backup commands exist when spatie/laravel-backup is installed.
The published file may keep only the keys it changes. Every key is a whole: a key the published file declares replaces
the default value and never mixes with it, so a truncate list that extends the default repeats the default tables.
Safety
- The copy writes only to the local database.
db:importrefuses to run whenapp.envisproduction, and on a database listed inprotected_databases(testingby default).db:restorerefuses a protected database too. - The disaster-recovery restore (
--restore-production) refuses without a terminal, asks to retype the name of the production database, and the server compares what was typed with its own database name. - A load is one transaction.
db:importanddb:restorecommit only when the end of the dump has arrived: a cut stream, a failed statement or a failed scrub leaves the target as it was. - The live dump is an ssh stream: no dump file is written on the server.
- Neither the package nor the host script prints a row value. The ssh target and the paths live in the local
.env. - ssh and the age private key stay on the developer's machine, and no S3 access is needed there: the server reads the
backup disks. Only the zip password (
DB_SYNC_ZIP_PASSWORD) reaches the local container, by name throughdocker compose exec -e.
The host script
Run it from the root of the project, on the developer's machine:
vendor/bin/db-sync # live dump of production (default)
vendor/bin/db-sync --anonymize # the same, with db.anonymize applied
vendor/bin/db-sync --backup # from the newest backup
vendor/bin/db-sync --backup=2026-10-26-03-00-00 # from this backup
vendor/bin/db-sync --list # the backups of the server
vendor/bin/db-sync --restore-production=<id> # restore a backup into PRODUCTION
| Mode | What it does |
|---|---|
| (none) | db:dump on the server, over ssh -C, piped into db:import in the local container. |
--backup[=<id>] |
db:backup:stream on the server (the newest backup, or the one with this id), decrypted with age when DB_SYNC_AGE_KEY is set, piped into db:import. |
--list |
db:backup:list on the server. |
--restore-production=<id> |
db:backup:stream, decrypted with age when DB_SYNC_AGE_KEY is set, piped into db:restore on the server. The id is required. |
--anonymize goes with the live dump and --backup. A second mode, or --anonymize with --list or
--restore-production, prints the usage and exits with 2. --help prints the usage and the settings.
Settings
The settings come from the environment, else from the DB_SYNC_* lines of ./.env. They belong in the local, unversioned
.env.
| Setting | Default | Meaning |
|---|---|---|
DB_SYNC_SSH_HOST |
none (required) | The ssh host of the server. A host that starts with a dash is refused. |
DB_SYNC_PATH |
/var/www/html/<name of the current directory> |
The project directory on the server. It must be an absolute path. |
DB_SYNC_SERVICE |
app |
The compose service of the application on the server. |
DB_SYNC_COMPOSE_FILE |
compose.prod.yaml |
The compose file on the server. |
DB_SYNC_LOCAL_SERVICE |
app |
The compose service of the application on this machine. |
DB_SYNC_AGE_KEY |
none | The age identity file that decrypts the backups. Used by --backup and --restore-production. |
DB_SYNC_ZIP_PASSWORD |
none | The zip password of the backups, read by the local db:import as db.zip_password. |
.envis parsed line by line, never sourced or evaluated: a value is data, never a command. Only theDB_SYNC_*names above are read. Quotes and a trailing comment are dropped, nothing is expanded, and a variable set in the environment wins over the file.- The login shell of the server must be POSIX sh or bash: the commands travel as one string that the shell parses, every value quoted.
- Several processes share the terminal (ssh,
age,docker compose). Rely on ssh-agent orControlMaster, and on an age identity without a passphrase.
Restoring a backup into production
--restore-production=<id> replaces the content of the production database with a backup. The restore is not scrubbed.
Find the id with vendor/bin/db-sync --list.
Before it, on the server:
- Put the application in maintenance mode:
php artisan down. - Stop the queue workers. The reset waits for the locks they hold on the tables.
Then run the script from a terminal. It prints which backup goes to which host, asks to type the name of the production
database, and restores. Afterwards php artisan up and start the workers again.
- The tables are locked from the start of the restore until it is committed: the reset takes its locks before the archive is read, so nothing else reads or writes the database while the archive is transferred to the server, checked and loaded. A restore that fails or is interrupted (SIGINT, SIGTERM) leaves the database as it was.
db:restorereads zip archives only. The CRC32 of the dump in the archive is verified before the restore commits; plain SQL and gzip carry none, so they are refused with exit code 1 and the database is left as it was.db:restorereads the archive as a stream: nothing of it is written to the server's disk. It refuses, with exit code 1 and the database left as it was, an archive with a zip password (age protects the backups), an archive whose entries carry their sizes after their data, and an archive that does not hold exactly one stored or deflated.sqlor.sql.gzdump underdb-dumps/. The archives ofspatie/laravel-backupwritten without a zip password meet these conditions.- The restore resets the target: every schema is dropped (
DROP SCHEMA … CASCADE, exceptpg_*andinformation_schema), the large objects are deleted,publicis created again, and the archive is loaded. The lines of the archive thatGRANT,REVOKE,ALTER DEFAULT PRIVILEGESor change an owner (OWNER TO) are skipped. After a restore,publicbelongs to the connecting role and nothing is granted on it toPUBLIC: aPUBLICusage grant on a schema, or any custom privilege on a schema, table or sequence, is not restored. Grant it again if the application relies on it. The connecting role must be allowed to drop every schema of the database and to createpublic, and must own the large objects of the database, which the reset deletes withlo_unlink.
What the import changes
db:import loads the dump into the default connection of the local application, then runs the scrubs in the same
transaction, before the commit: truncate, encrypted, with --anonymize the anonymize entries, and the refresh of the
materialized views.
truncate, encrypted and anonymize act on the tables of the public schema only, and so does the gate
db:anonymize:check: a table of another schema is neither scrubbed nor reported.
| Key | Default | Meaning |
|---|---|---|
truncate |
sessions, cache, cache_locks, jobs, job_batches, failed_jobs, notifications, password_reset_tokens, personal_access_tokens |
The tables emptied by the import, by their name in the public schema. A table the database does not have is skipped. |
encrypted |
users.two_factor_secret and users.two_factor_recovery_codes, both null |
The columns replaced with a value encrypted under the local APP_KEY. |
anonymizers |
[] |
The anonymizers the application adds to the built-in ones. |
anonymize |
[] |
What --anonymize masks. |
not_personal |
[] |
The table.column entries that db:anonymize:check does not report. |
model_paths |
[app_path('Models')] |
The directories db:anonymize:check reads the models from. |
protected_databases |
['testing'] |
The databases db:import and db:restore refuse to overwrite. |
zip_password |
env('DB_SYNC_ZIP_PASSWORD') (null) |
The password of the zip archives the local side opens. |
The input of db:import can be a plain SQL dump, a gzip stream or a spatie/laravel-backup zip archive that holds one
db-dumps/*.sql or .sql.gz entry.
Truncate
Add the tables of the project that hold tokens, such as OTP codes or invitations, next to the default ones:
// config/db.php
'truncate' => [
'sessions', 'cache', 'cache_locks', 'jobs', 'job_batches', 'failed_jobs', 'notifications',
'password_reset_tokens', 'personal_access_tokens',
'otp_codes', 'invitations',
],
The tables go into one TRUNCATE without CASCADE: tables that reference each other can be listed together, and a
table outside the list that references one of them stops the import.
Encrypted columns
The production values of an encrypted cast are unreadable locally, as they were encrypted with another key. An entry
maps a table to its columns, and a column to a sample. Each non-null row gets its own encryption of the sample under the
local key, so a unique index on the column holds, and a null value stays null.
'encrypted' => [
'bank_accounts' => ['iban' => 'CH93 0076 2011 6238 5295 7'],
'users' => ['two_factor_secret' => null, 'preferences' => '{}'],
],
- The sample is the plain text the cast would encrypt. For
encryptedand the scalar casts it is any string. Forencrypted:array,encrypted:collection,encrypted:object,AsEncryptedArrayObjectandAsEncryptedCollectionit is a JSON string, because Laravel decodes the value after decrypting it:'[]'or'{}', never''. - A
nullsample sets the column toNULL, for a nullable column only. - A non-null sample needs
app.cipherset toaes-256-cbcand thepgcryptoextension, which is created for the import and dropped after it if the database lacked it. Null samples need neither. - When the dump installs
pgcryptoin a schema outside the defaultsearch_path(Supabase usesextensions), the import cannot call its functions: with a non-null sample it fails and rolls back, and the target is untouched. - A table or column the database does not have is skipped.
Anonymization
--anonymize masks the columns of anonymize, a map of table to map of column to the name of an anonymizer. A null value
stays null. Each table gets one UPDATE, in the import transaction.
'anonymizers' => [
'iban' => App\Anonymizers\SwissIban::class,
],
'anonymize' => [
'users' => ['email' => 'email', 'name' => 'name', 'phone' => 'null'],
'counterparties' => ['notes' => 'text', 'iban' => 'iban'],
],
| Name | Replaces a value with |
|---|---|
email |
user-<primary key>@example.test |
name |
Person <primary key> |
phone |
+41 00 and nine digits: the primary key, zero-padded, when it is a whole number of nine digits or fewer, a hash of the key otherwise |
text |
lorem ipsum, repeated and cut to the length of the original |
hash |
the MD5 digest of the value, a pseudonym: equal values stay equal, and a dictionary of likely values (emails, phone numbers) finds the original again |
null |
NULL, for a nullable column only |
value:<text> |
the literal text, for example value:redacted |
email, name and phone depend only on the primary key: the same row gives the same value on every import. email
and name are unique per row. phone is the key itself, zero-padded to nine digits, when the key is a whole number of nine
digits or fewer (written without leading zeros): such numbers are unique. For any other key (a UUID, a longer number) it is
derived from a hash of the key, so two rows can in principle share a number. They need a table with a single-column primary
key (an INCLUDE clause on the key is fine); any other table is refused. text, null and value: give equal values
to different rows, so they do not suit a unique column.
A name in anonymizers takes precedence over a built-in one of the same name. An unknown name refuses the import before
anything is read. A table or column the database does not have rolls the import back.
The scrubs are plain UPDATE and TRUNCATE statements that run after the dump is loaded. A materialized view stores
the rows it was filled with from the production tables, so the import refreshes every populated materialized view
(REFRESH MATERIALIZED VIEW, in creation order, so that a view built on another one is refreshed after it) as the last
step of the same transaction, with or without --anonymize: a view shows the masked values, and none of the rows of a
truncated table. A view dumped WITH NO DATA stays empty. Do not list a materialized view in truncate: PostgreSQL
refuses to TRUNCATE a materialized view, and the import fails.
A custom anonymizer
A custom anonymizer implements UnDevSuisse\Db\Anonymizer and is built by the container. It returns the SQL expression
that PostgreSQL evaluates for every non-null row:
namespace App\Anonymizers;
use Illuminate\Contracts\Database\Query\Expression as ExpressionContract;
use Illuminate\Database\Query\Expression;
use UnDevSuisse\Db\Anonymizer;
final class SwissIban implements Anonymizer
{
public function expression(string $table, string $column): ExpressionContract
{
return new Expression("'CH00 0000 0000 ' || lpad({key}::text, 9, '0')");
}
}
$tableand$columnare plain names, not quoted: the expression quotes an identifier itself.- The expression is a single value expression, placed after
ELSEin aCASE. {key}stands for the quoted single-column primary key of the table. An expression built on it gives the same value for the same row on every import. A table without such a key is refused.
Checking the configuration
php artisan db:anonymize:check
Reads the base tables of the public schema of the default connection, and the models under model_paths. It never reads
a row. It prints a Column | Problem table and exits with 1 when there is a row; otherwise it prints Every personal-looking column is covered. and exits with 0, so it serves as a CI gate on a migrated database.
| Problem | Meaning |
|---|---|
looks personal, not covered |
A text or JSON column whose name looks personal (email, phone, name, address, iban, token, password…), in no table of truncate and in none of anonymize, encrypted, not_personal. |
encrypted cast, no entry in encrypted |
A column that a model casts as encrypted, or one of Fortify's users.two_factor_* columns, with no encrypted entry. |
null on a NOT NULL column |
The anonymizer null or a null sample on a column that is not nullable. |
unknown anonymizer <name> |
A name that is neither built-in, in anonymizers, nor value:, or a class that is not an anonymizer. |
column does not exist |
An anonymize entry whose table or column is missing. |
malformed entry |
An anonymize or encrypted value of the wrong shape. |
model cannot be built |
A model under model_paths whose construction throws. |
A column that is personal in name only goes in not_personal as table.column. Views are not inspected.
Backups
With spatie/laravel-backup installed, the package adds the backup commands. They work on the destination disks of
backup.backup.destination.disks. A disk on S3 needs league/flysystem-aws-s3-v3.
// config/filesystems.php
'backups-local' => ['driver' => 'local', 'root' => storage_path('backups')],
'backups-s3' => ['driver' => 's3', 'bucket' => env('BACKUP_S3_BUCKET'), /* … */],
// config/backup.php
'disks' => explode(',', env('BACKUP_DISK', 'backups-local')),
A local backup disk on the server must sit on a Docker volume, otherwise a recreated container loses its archives.
| Command | Where | What it does |
|---|---|---|
db:backup |
server | Runs backup:run --only-db, on the disks and with the password of spatie/laravel-backup. |
db:backup:list |
server | Lists the archives of every disk, newest first: Id, Date, Size, Disk. A disk that cannot be listed is named on stderr and the exit code is 1. |
db:backup:stream {id?} |
server | Writes an archive to stdout as stored, the newest or the one with this id. |
db:dump |
server | Writes a plain SQL pg_dump (no owners, no privileges) of the default connection to stdout. |
db:import {--anonymize} |
local | Loads a dump read on stdin, scrubbed. |
db:restore {--confirm=} |
server | Loads a zip archive read on stdin, unscrubbed, if --confirm is exactly the database name. |
db:anonymize:check |
local | The gate described above. |
Every server command writes its data to stdout and its messages to stderr, and exits non-zero on failure: db:dump
returns the exit code of pg_dump, db:backup that of backup:run, the others exit with 1.
A backup id is the name of its archive without .zip (<prefix>Y-m-d-H-i-s). The same id on two disks is two entries;
the first disk of the configuration answers.
Schedule db:backup in the application, as any command.
Encrypted backups
Archives encrypted with age (files-storage.backups.age_recipients of un-dev-suisse/laravel-files-storage) leave the
server encrypted. db-sync decrypts them with the local age private key, DB_SYNC_AGE_KEY, which never leaves the
developer's machine. An archive encrypted with age that reaches db:import or db:restore without being decrypted is refused. An archive
that has a zip password is opened by db:import with DB_SYNC_ZIP_PASSWORD; db:restore refuses it.
Development
docker compose run --rm php composer check # pint, larastan, phpunit, shellcheck
docker compose down