Strangling a legacy database into Laravel (my first big migration, mistakes included)
6 min read
This is a post about my first big Laravel app, which means it’s partly a post about being wrong in
public. The app was a CRM for a call-center operation, and it wasn’t starting from zero — it had to take
over from a legacy PHP system that had been running for years, with a database schema only its original
authors could love: tables called lead, hotkey, history, primary keys like id_lead and id_hk,
dates stored as "0000-00-00", and columns misspelled in ways everyone had learned to live with. My job
was to build the new app and bring all of that data across, cleaned and normalized, without a day of
downtime.
I got a lot of it right and some of it embarrassingly wrong, and both halves taught me more than any greenfield project has since. Here’s the real thing.
Don’t let the old schema infect the new one
The instinct when you inherit a bad schema is to read from it directly all over your new code. Resist
it — every place that touches id_hk or the misspelled column becomes a place the legacy mess leaks
into your clean app. The pattern that saved me is what I later learned is called an anti-corruption
layer: a wall of thin, read-only models that speak the old schema’s language so nothing else has to.
In Laravel this is almost free, because Eloquent can point at a second database connection. I defined
one in config/database.php, entirely env-driven:
'mysql2' => [
'driver' => env('DB_CONNECTION_SECOND'),
'host' => env('DB_HOST_SECOND'),
'database' => env('DB_DATABASE_SECOND'),
'username' => env('DB_USERNAME_SECOND'),
'password' => env('DB_PASSWORD_SECOND'),
],
Then a small adapter model per legacy table, each one just declaring the connection, the old table name, and the old primary key:
class LeadAdapter extends Model
{
protected $connection = 'mysql2';
protected $table = 'lead';
protected $primaryKey = 'id_lead';
// ...the pile of id_* foreign keys the old schema used to join everything
}
I ended up with a couple dozen of these — one per legacy table. They’re boring on purpose. Nothing in them does logic; they exist so that the ugliness of the old schema is quarantined behind a handful of files, and the rest of the app only ever sees clean, new models. That boundary is the single best decision in the whole migration.
The migration is a data-cleaning pipeline wearing a loop
The actual move lived in one console command that chunked through the legacy tables and re-created each record in the normalized schema. The bones of it:
LeadAdapter::chunk(1000, function ($claims) {
foreach ($claims as $claim) {
$oldClient = ClientAdapter::where('id_client', $claim->id_client)->first();
Client::create([
'name' => $oldClient->name,
'date_of_birth' => $oldClient->dob == "0000-00-00" ? null : $oldClient->dob, // legacy zero-dates
'registration' => $oldVehicle->rego, // rename as we go: rego -> registration
'insurer' => $oldVehicle->inssurer, // ...and quietly fix old typos
]);
}
});
chunk(1000) is the load-bearing part: the source table was large, and reading it all at once would
blow the process up. Chunking pulls a thousand rows, processes them, and moves on, so memory stays
bounded. The rest is the unglamorous reality of every real migration — translating "0000-00-00" (a
MySQL non-date) into a real null, renaming rego to registration, papering over a column someone
had spelled inssurer in 2014. A migration is 10% moving data and 90% deciding what to do with the
data that doesn’t fit.
The part I was proudest of, and still am: the legacy system tracked status changes in a history table,
and the new app used a proper audit trail. So I replayed that history — walking each record’s old
status changes in order and writing them into the new audits table as synthetic created/updated
events, carrying the previous values forward so the trail read as if the new system had recorded it all
along:
$previous = ["status_id" => $firstStatus->id ?? 1];
foreach ($history as $row) {
Audit::create([
"event" => "updated",
"old_values" => $previous,
"new_values" => ["status_id" => $row->status_id ?? 1],
"auditable_type"=> "App\\Claim",
"auditable_id" => $newClaimId,
"created_at" => $row->time_stamp, // preserve the ORIGINAL timestamp
]);
$previous = ["status_id" => $row->status_id ?? 1];
}
Preserving the original timestamps meant the reconstructed history lined up with reality — a manager could look at a migrated record and see its real timeline, not the migration date stamped on everything. Reconstructing history that a system never explicitly stored, from the breadcrumbs it left, is a genuinely satisfying thing to pull off.
Now the parts I’d do differently
Here’s where being honest is more useful than looking good, because these are the mistakes a first big migration teaches you, and I made all of them.
I swallowed failures. Scattered through the command were catch blocks like this:
} catch (\Illuminate\Database\QueryException $e) {
// do what you want here with $e->getMessage();
}
An empty catch that eats a failed row and moves on. At the time it felt pragmatic — don’t let one bad record halt a migration of hundreds of thousands. But “don’t halt” and “don’t record” are different choices, and I made the wrong second one: a row that failed to migrate just vanished, silently, with no list of what was lost. What I’d do now is write every failure to a dead-letter table — the source id, the exception, the raw row — so the migration finishes and I have an exact, re-runnable list of what didn’t make it. A migration you can’t audit is a migration you have to trust blindly, and you should never trust a data migration blindly.
I disabled the framework instead of working with it. To stop model events firing during the bulk
insert, I reached for Claim::unsetEventDispatcher(). It works, but it’s a sledgehammer — it globally
mutes events for the rest of the process, and it’s the kind of thing that causes a baffling bug six
months later when someone runs the command in a context that needed those events. The right tools were
there: Model::withoutEvents(fn () => ...) scopes it to a closure, or better, skip Eloquent entirely for
the bulk path and use a batched insert() / upsert.
Everything was create, and everything was N+1. Each row did a dozen ->first() lookups to resolve
its foreign keys, and blindly created new records — so re-running the command would duplicate
everything. ini_set('memory_limit', '-1') was papering over the fact that I was loading far more into
memory than I needed. The mature version pre-loads the lookups into keyed maps once, uses
updateOrCreate / upsert on a stable key so the migration is idempotent and re-runnable, and wraps
each chunk in a transaction so a mid-chunk failure rolls back cleanly instead of leaving half a record.
None of those fixes is exotic. I just didn’t know to reach for them yet, and a real migration under a real deadline is exactly how you learn to. The anti-corruption layer and the history reconstruction are the parts I’d keep unchanged; the error handling and the idempotency are the parts that separate “it worked that one time” from “I could run it again tomorrow and trust the result.” That gap — between working once and being trustworthy — is most of what I’ve learned since.
I’ve since done data migrations I’d defend without the caveats. If you’re strangling a legacy system into a new one and want someone who’s made these mistakes already, get in touch. Related: building the dashboards for this same CRM, the slow way then the right way.