jord-jd/uxdm
UXDM is a PHP data migration library for moving records between sources and destinations such as CSV, JSON, XML, PDO and Eloquent. Map fields, transform values, and run repeatable imports or exports through a simple, extensible API.
UXDM is a plain Composer library — there is no service provider, facade, config file, or Laravel integration. You composer require jord-jd/uxdm and drive it from a command, job, or test.
The mental model (learn this in 5 minutes):
Migrator (source) ──migrate()──► DataSet (Items) ──destination->performMigration()──► Destination (sink)
JordJD\UXDM\Objects\Migrators\*): ArrayMigrator, CSVMigrator, JSONMigrator, PDOMigrator, EloquentMigrator, BulkCSVMigrator....\Destinations\*): the same family minus the bulk reader....\Fields\*): Field (1:1 copy), FieldCallable (transform), FieldValue (constant). You declare these on the migrator with setFromFieldName() / setToFieldName().Item ≈ one row; each carries the toFieldName keys the destination writes.First use case — legacy table → users table, in php artisan tinker or a command:
use JordJD\UXDM\Objects\Migrators\PDOMigrator;
use JordJD\UXDM\Objects\Destinations\PDODestination;
use JordJD\UXDM\Objects\Fields\Field;
$migrator = new PDOMigrator(DB::connection('legacy')->getPdo());
$migrator->setTableName('members');
$migrator->setFields([
(new Field())->setFromFieldName('id')->setToFieldName('legacy_id'),
(new Field())->setFromFieldName('email')->setToFieldName('email'),
]);
$dataSet = $migrator->migrate(); // pulls everything into memory
$destination = new PDODestination(DB::connection()->getPdo());
$destination->setTableName('users');
$destination->performMigration($dataSet);
Where to look first in the repo: src/Objects/Migrators (what sources you can read), src/Objects/Destinations (what sinks exist), then src/Objects/Fields — 90% of your day-to-day work is choosing migrator + destination and writing field maps.
1. Config-driven field maps. Don't hardcode maps in the command; keep them in config/migration.php and translate to field objects so the same command migrates many tables.
// config/migration.php
'users' => [
'source' => ['connection' => 'legacy', 'table' => 'members'],
'fields' => [
['from' => 'id', 'to' => 'legacy_id'],
['from' => 'email', 'to' => 'email'],
['from' => 'name', 'to' => 'name', 'callable' => 'trim'],
['value' => now(), 'to' => 'created_at'],
],
],
2. Use FieldCallable for every shape change — date normalisation, slugging, password hashing, enum mapping, FK lookups:
$field = new FieldCallable();
$field->setFromFieldName('joined');
$field->setToFieldName('created_at');
$field->setCallable(fn ($value) => Carbon::parse($value, 'Europe/London')->utc());
Use FieldValue for constants (tenant id, created_at, email_verified_at) rather than post-processing rows.
3. Chunk large tables yourself. PDOMigrator::migrate() hydrates the whole table. For anything beyond a few tens of thousands of rows, loop with Laravel's chunkById() and a fresh ArrayMigrator per chunk — you keep the field-mapping layer while bounding memory:
DB::connection('legacy')->table('members')->orderBy('id')->chunkById(2000, function ($rows) use ($fields, $destination) {
$migrator = new ArrayMigrator($rows->map(fn ($r) => (array) $r)->all());
$migrator->setFields($fields);
$destination->performMigration($migrator->migrate());
});
Prefer BulkCSVMigrator / BulkCSVDestination when the intermediate format is a file — they stream instead of buffering.
4. Wrap in a command with a progress bar + --dry-run, and dispatch per-table jobs from a parent command so one failure doesn't kill the run. A --dry-run that only prints $dataSet->count() and the first 5 items will catch most field-mapping errors before you touch production.
5. EloquentDestination when you want model behaviour (casts, mutators, observers on the destination side), PDODestination when you want raw throughput or are writing to a foreign connection. A common split: PDO for bulk backfills, Eloquent for small/relational-adjacent imports.
6. Idempotency pattern. UXDM destinations insert; they don't upsert. For re-runnable migrations, either truncate the target in a transaction first, or skip this layer and use Model::upsert() / insertOrIgnore() after a dry-run diff. Record legacy_id on the target so a second run can be reconciled.
7. Test with ArrayMigrator. No DB needed: feed arrays, assert on the resulting DataSet items, or wire an ArrayDestination and compare arrays. This is the fastest feedback loop and catches field-name typos.
8. Custom endpoints are first-class. Implement the Migrator or Destination interface when you need an API source (Shopify, HubSpot, an S3 JSON dump) or a upsert-style sink. Wrap an existing destination in a decorator if you only need logging or row-count assertions.
Item object per row with a field object per column — a 500k-row import can easily be 1GB+. Use ->chunkById() + ArrayMigrator, or the Bulk* classes. Also call DB::disableQueryLog() around the loop; Laravel's query log will happily eat the rest of your RAM.id => new_id map and use it inside a FieldCallable), and be aware the destination driver may not enforce FK order for you.PDODestination builds wide inserts. Chunk sizes that felt fine at 2k rows can blow past MySQL's 65,535 placeholder limit or max_allowed_packet when rows have many columns. Halve the chunk size before you blame the library.saving hooks, and updated_at churn will silently change your data or slow the import drastically. Use Model::withoutEvents() / saveQuietly() semantics and set timestamps explicitly via FieldValue.fromFieldName fails quietly. If the source column doesn't exist or is misspelled, you generally get null, not an exception — the row still inserts. Assert non-null counts on critical fields and add a --dry-run diff, or you'll discover empty emails three weeks later."1", "true", ""), decimals with commas, dates in the source system's locale — normalise inside FieldCallable and cast deliberately; don't rely on the destination column type to coerce.jord-jd/uxdm but the PHP namespace is JordJD\UXDM (hyphens aren't legal in namespace segments). If IDE autocomplete lets you down, type the use statement by hand.composer show jord-jd/uxdm against the tags before copying any example.DataSet level, not the destination. $dataSet->getItems() before performMigration() tells you whether the bug is in your source/mapping or in the sink. Nine times out of ten it's a field name or a null from an unmapped column.Destination that wraps PDODestination and does upsert/logging/validation is twenty lines and keeps you upgradable.How can I help you explore Laravel packages today?