mb4it / laravel-dbtodb-migration
Laravel artisan command to migrate data between database connections (1 source → N targets) with configurable table/column mapping, chunking, and optional profiling logs.
Package info
github.com/Dictator90/laravel-dbtodb-migration
pkg:composer/mb4it/laravel-dbtodb-migration
Requires
- php: ^8.2
- laravel/framework: ^v12
Requires (Dev)
- orchestra/testbench: ^10.0
- phpunit/phpunit: ^11.0
This package is auto-updated.
Last update: 2026-04-17 12:11:33 UTC
README
Laravel 12 package: an Artisan command that copies rows from one source database connection to one or more target tables on another connection, driven by config/dbtodb_mapping.php. Supports chunked reads, optional keyset pagination, per-target column maps and transforms, filters (including exists_in on the source query), upsert vs insert, strict validation, JSON reports, and optional profiling logs.
Requirements
- PHP
^8.2 - Laravel
^12.0
Installation
composer require mb4it/laravel-dbtodb-migration
Laravel discovers the service provider via extra.laravel.providers in composer.json. No manual registration is required.
Developer guide: from zero to a working run
What you are configuring
- One source connection, one target connection (any names; you pass them to the command).
tables— which source tables participate. Each source table becomes one pipeline: read from source → map/transform → write to one or many target tables.columns,transforms,filters,upsert_keys, andruntime.tables.{source}are always keyed by source table name, never by step name (if you use stagedtables, see below).
Checklist
- Install the package (
composer requireabove). - Publish config:
php artisan vendor:publish --tag=dbtodb-migration-config— you getconfig/dbtodb_mapping.php. - Define two connections in
config/database.php(for examplesourceandtarget, orlegacy_mysqlandpgsql_app). Point them at real databases. - Edit
dbtodb_mapping.php:- Set
tablesto your source → target mapping. - Set
columnsfor eachsource_table→target_table(see Config:dbtodb_mapping). - Add
transformsandfiltersas needed (empty arrays are fine to start).
- Set
- Optional: add a log channel named like
db_to_dbinconfig/logging.phpif you plan to use--profile(see Profiling log channel). - First run: use
--dry-runto validate and read without writing, then run without it for a real copy.
Minimal flat tables example
After publishing, replace the sample with your names. Single target per source:
'tables' => [ 'legacy_users' => 'users', ], 'columns' => [ 'legacy_users' => [ 'users' => [ 'id' => 'id', 'email' => 'email', ], ], ], 'transforms' => [ 'legacy_users' => [ 'users' => [], ], ], 'filters' => [ 'legacy_users' => [], ],
Multiple targets from one source
Use an array of target table names in tables, then nest columns and transforms under each target:
'tables' => [ 'events' => ['app_events', 'analytics_events'], ], // columns.events.app_events[...], columns.events.analytics_events[...]
Staged runs (runtime.steps_in_tables)
Use this when you must run groups of tables in order (for example dimensions first, then facts), but still want one config file.
- Set
runtime.steps_in_tablestotrue(in config or viaDB_TO_DB_STEPS_IN_TABLES=true). - Change
tablesto step name → inner map (not a numeric list at the top level):
'runtime' => [ // ... 'steps_in_tables' => true, ], 'tables' => [ 'dimensions' => [ 'legacy_countries' => 'countries', 'legacy_cities' => 'cities', ], 'facts' => [ 'legacy_orders' => 'orders', ], ],
- No
--step: all steps run in the order keys appear in thetablesarray; the command builds one ordered list of pipelines (one progress bar for the whole run). --step=dimensions: only pipelines for that step’s sources.- The same source table must not appear in two steps when you run all steps at once (configuration error).
--tables=still limits by source table names after the effective map is resolved (flat or merged steps).
With steps_in_tables false (default), --step is invalid and triggers a clear RuntimeException.
Command examples
# Defaults: connections "source" and "target" php artisan db:to-db # Explicit connections (typical in real apps) php artisan db:to-db --source=legacy_mysql --target=pgsql_app # Only some source tables (comma-separated, no spaces unless quoted) php artisan db:to-db --source=legacy_mysql --target=pgsql_app --tables=legacy_users,legacy_roles # Validate and read; no writes (good first check) php artisan db:to-db --dry-run --source=legacy_mysql --target=pgsql_app # Staged config: run one step only php artisan db:to-db --source=legacy_mysql --target=pgsql_app --step=facts # Custom JSON report path (default is under storage/logs/ with a timestamp) php artisan db:to-db --report-file=storage/logs/db-to-db-last.json # Keep going if one pipeline fails (check exit code and report anyway) php artisan db:to-db --continue-on-error # Per-chunk timing logs (needs channel in logging.php) php artisan db:to-db --profile
Publish configuration
php artisan vendor:publish --tag=dbtodb-migration-config
This copies config/dbtodb_mapping.php. Replace the example source_items → items mapping with your real source and target tables.
Profiling log channel
dbtodb_mapping.profile_logging is the name of a Laravel log channel (same string you pass to Log::channel()). Define that channel in your app’s config/logging.php under channels (for example db_to_db with daily or stack), like any other channel. The package does not register channel drivers for you.
Database connections
Define two connections in config/database.php — one source, one target (names are yours). The Artisan command defaults assume connections named source and target; override with --source and --target if you use different names.
Use .env for host, database, user, and password. The package does not read credentials directly.
Example .env fragment (wire these keys in config/database.php for your chosen connection names):
SOURCE_DB_CONNECTION=mysql SOURCE_DB_HOST=127.0.0.1 SOURCE_DB_DATABASE=source_db SOURCE_DB_USERNAME=root SOURCE_DB_PASSWORD= TARGET_DB_CONNECTION=pgsql TARGET_DB_HOST=127.0.0.1 TARGET_DB_DATABASE=app TARGET_DB_USERNAME=postgres TARGET_DB_PASSWORD=
Command: php artisan db:to-db
| Option | Default | Description |
|---|---|---|
--source= |
source |
Laravel database connection name for reads (see config/database.php). |
--target= |
target |
Laravel database connection name for writes. |
--tables= |
(all allowed sources) | Comma-separated source table names. Must be a subset of the sources that the config resolves to (after flattening tables / --step). |
--step= |
(empty) | Only when runtime.steps_in_tables is true: run a single named step’s tables map. Omit to run every step in config order. |
--dry-run |
off | Build pipelines, validate, read source data in chunks; no inserts/upserts on the target. |
--continue-on-error |
off | After a pipeline failure, continue with the next pipeline. Inspect the console table and JSON report for failed rows. |
--report-file= |
auto path under storage/logs/ |
Streaming JSON report (suitable for large runs). |
--profile |
off | Emit timing logs (per pipeline / chunk) to the channel in dbtodb_mapping.profile_logging. |
The command prints a summary table (and optional verbose timing with -v). On configuration or runtime errors it prints a sanitized message and exits with a non-zero code.
Environment variables (published config)
These are read only from config/dbtodb_mapping.php (the package does not call env() from src/). Typical mappings:
| Variable | Config area | Role |
|---|---|---|
DB_TO_DB_LOG_CHANNEL |
profile_logging |
Log channel name for --profile. |
DB_TO_DB_MAX_ROWS_PER_UPSERT |
runtime.defaults.max_rows_per_upsert |
Cap rows per single SQL upsert/insert batch. |
DB_TO_DB_MEMORY_LOG_EVERY_CHUNKS |
runtime.memory.memory_log_every_chunks |
Log peak memory every N chunks (0 = off). |
DB_TO_DB_FORCE_GC_EVERY_CHUNKS |
runtime.memory.force_gc_every_chunks |
Run gc_collect_cycles() every N chunks. |
DB_TO_DB_SLOW_CHUNK_SECONDS |
runtime.profile_slow_chunk_seconds |
With --profile, warn when a chunk step exceeds this duration. |
DB_TO_DB_STEPS_IN_TABLES |
runtime.steps_in_tables |
Enable staged tables layout (true / false / 1 / 0 via filter_var). |
DB_TO_DB_MEMORY_LIMIT |
runtime.cli_memory_limit |
Optional PHP memory_limit for the Artisan process. |
Config: dbtodb_mapping
All keys below live under the dbtodb_mapping config array.
| Key | Purpose |
|---|---|
strict |
If true, target columns must exist and required NOT NULL columns without defaults must be present in the payload. |
tables |
Maps each source table name to one target table name (string) or several (array of strings). When runtime.steps_in_tables is true, use step keys instead: each step is step_name => [ source_table => target or [targets…] ] (same inner rules). Run one step with php artisan db:to-db --step=step_name, or omit --step to run all steps in config order. |
runtime.steps_in_tables |
If true, tables must be a non-list map of named steps (see above). Duplicate source tables across steps are rejected when running all steps. Env: DB_TO_DB_STEPS_IN_TABLES (boolean). Default false keeps the flat tables shape. |
columns |
columns[source_table][target_table][source_col] = target_col. For a single target, still use the nested [target_table] level. A non-empty map limits the source SELECT to those keys (plus filters / keyset). An empty map for that target means SELECT * and every column is read and copied. Omitting columns.{source_table} behaves like empty maps. |
transforms |
Per-source, per-target column transforms. Table-level rules share transforms.{source_table} with keys that are not target table names. |
filters |
Source filters: either a flat rule list applied to the source query, or a per-target shape with default and optional overrides per target table. |
runtime.defaults |
chunk (rows read/mapped per batch), max_rows_per_upsert (max rows per SQL insert/upsert; prevents huge binding arrays / OOM on PostgreSQL, default 500). Env: DB_TO_DB_MAX_ROWS_PER_UPSERT. transaction_mode: batch or atomic. |
runtime.memory |
memory_log_every_chunks (0 = off), force_gc_every_chunks (0 = off). Env: DB_TO_DB_MEMORY_LOG_EVERY_CHUNKS, DB_TO_DB_FORCE_GC_EVERY_CHUNKS. |
runtime.profile_slow_chunk_seconds |
With --profile, chunk steps slower than this log as warning. Env: DB_TO_DB_SLOW_CHUNK_SECONDS. |
runtime.tables.{source_table} |
Optional overrides per source table key from tables: chunk, transaction_mode, keyset_column (stable ascending column for keyset pagination). |
runtime.cli_memory_limit |
Optional PHP memory_limit for the Artisan process (e.g. 512M). Can also be driven via DB_TO_DB_MEMORY_LIMIT in the published config. |
auto_transforms |
enabled, bool, bool_columns (map target table name → list of columns forced to boolean coercion). |
upsert_keys |
Optional conflict columns for upsert: upsert_keys[source_table] = ['id'] or upsert_keys[source_table][target_table] = ['slug'] when the target PK cannot be discovered or you need an override. |
profile_logging |
Log channel name (string, default db_to_db). Must exist in config/logging.php. Env: DB_TO_DB_LOG_CHANNEL. Used with --profile. |
Filters
Supported operators on the source query include: =, !=, >, >=, <, <=, in, not_in, like, not_like, null, not_null, between, not_between, and exists_in.
exists_in builds a semi-join against another table (see DbToDbSourceReader). It is only valid on source filters; target-side filters do not support exists_in.
Column transforms
Transforms run before target type coercion. Each source column can use a single rule or a list applied in order.
- Strings:
trim,null_if_empty,zero_date_to_null. - Objects (use a
rulekey):if_eq—{ "rule": "if_eq", "value": <any>, "then": <any> }replaces the cell when it equalsvalue.multiply—{ "rule": "multiply", "by": <number> };nullstaysnull.round_precision—{ "rule": "round_precision", "precision": <int> }runs PHProundon the current value. Optional branch:"when": { "column": "idb", "in": [755] }, "then_precision": 0usesthen_precisionwhen the named source column is inin(same row as the mapped value).invoke—{ "rule": "invoke", "using": ["SomeClass", "handle"] }where the callable is(mixed $value, array $sourceRow).
- Closures in the rule list:
(mixed $value, array $sourceRow)return the next value (useful when config PHP can hold closures; JSON-published config cannot).
Transforms receive the full source row so rules can depend on sibling columns (e.g. brand idb while transforming price).
Order of tables and foreign keys
Process parent tables before children, or use filters / exists_in so you only migrate rows that already have referential integrity on the target. With staged tables, order steps so dependencies are satisfied, or run --step manually in sequence.
Memory usage
Peak memory is roughly proportional to chunk size × row width in PHP, and to how many rows are packed into one upsert (Laravel flattens all parameters per statement). Keep runtime.defaults.max_rows_per_upsert modest (default 500); lower it further for very wide tables. The source SELECT list comes from columns: a non-empty map limits read columns; an empty map (single target) means SELECT *. Use runtime.cli_memory_limit / DB_TO_DB_MEMORY_LIMIT, runtime.memory, and --profile when tuning.
Typical failures
- Out of memory — reduce
runtime.defaults.chunk, lowermax_rows_per_upsert, narrowcolumnsmaps, or raiseruntime.cli_memory_limit/DB_TO_DB_MEMORY_LIMIT. - Foreign key violations — reorder pipelines or tighten filters.
- Upsert errors — ensure PostgreSQL has unique/PK columns matching upsert keys; use
upsert_keyswhen discovery is wrong.
Profiling and logs
--profileemits structured log lines (pipeline start/end, chunk read/write, slow chunk warnings).- Channel — string
dbtodb_mapping.profile_logging(defaultdb_to_db). Point it at a channel defined inconfig/logging.php(driver,path,level, etc. live there). - Slow chunk threshold —
dbtodb_mapping.runtime.profile_slow_chunk_seconds(default5), envDB_TO_DB_SLOW_CHUNK_SECONDSin the published config.
Troubleshooting
- Command not found — run
php artisan listand confirm the package is installed; clearbootstrap/cache/packages.phpif you disabled discovery. - Wrong connection — pass
--sourceand--targetexplicitly. - Empty pipelines —
dbtodb_mapping.tablesmust list your source tables (or your step must define them whensteps_in_tablesis true).--tablesmust be a subset of the resolved source keys. --steperrors — with flattables, do not pass--step. With stagedtables, the step name must exist as a key intables.- Strict mapping errors — disable
strictonly as a temporary debug step; fix column maps and required columns properly.
Developing this package
From the repository root (this package’s directory):
composer install vendor/bin/phpunit
License
MIT. See LICENSE.