clarkewing/legacy-sync
Sync data between legacy systems and modern Laravel applications. Provides configurable mappings, queued imports, and incremental updates so you can migrate or mirror old databases without downtime or custom scripts.
clarkewing/legacy-sync is a small utility package for keeping records in sync between your application's database and a legacy database (typically the "other" system you're migrating away from, or a downstream system you can't retire yet). It gives you a trait/method surface for pushing a record across connections without hand-rolling query builders every time.
Minimal setup:
composer require clarkewing/legacy-sync
php artisan vendor:publish --tag="legacy-sync-config"
config/database.php (legacy or whatever name you choose) and wire the credentials into .env. Use a dedicated, least-privilege user — in most deployments this connection only needs INSERT/UPDATE/DELETE on the tables you sync, not DROP/ALTER.config/legacy-sync.php.Where to look first:
config/legacy-sync.php — connection name, table mapping, any feature toggles.src/) — this is a thin package; reading the trait is faster than reading docs. You want to know exactly which methods exist and what they return.tests/ directory — it's the most reliable spec for edge-case behaviour (what happens when the row is missing, whether exceptions bubble).First use case: you create a record in the new app and immediately need it visible in the legacy DB. With v0.3.0 you now have delete parity, so the first use case can be the full lifecycle — create, update, and remove — rather than create/update only.
Pair models, don't share them. Keep your app model and a legacy-facing model separate (different connections, often different column names, sometimes different types). The sync method call belongs in a single service/action class, not scattered through controllers.
Drive sync from model events, but keep the work queueable.
static::created(fn ($model) => SyncLegacyRecord::dispatch($model, 'create'));
static::updated(fn ($model) => SyncLegacyRecord::dispatch($model, 'update'));
static::deleted(fn ($model) => SyncLegacyRecord::dispatch($model, 'delete'));
Queuing matters more than it looks: the legacy database is usually the least reliable thing in the stack, and you do not want a legacy timeout to fail a user's request. Give the job $tries, backoff(), and ideally ShouldBeUnique keyed on model class + id + operation so a burst of updates doesn't fan out into a stampede.
Treat the sync as an outbox. Write a legacy_sync_log row (model class, id, operation, payload, status, attempts) in the same transaction as your primary write, then have the job read from that log. This gives you replay, audit, and a way to reconcile after an outage — without it, a failed deleteRecord() mid-flight leaves you guessing.
Make operations idempotent. deleteRecord() should be safe to call when the row is already gone (a DELETE affecting 0 rows is success, not failure). Same for updates — consider upsert semantics on the legacy side so a retried job doesn't blow up.
Backfill with chunkById, not chunk. When rolling this out against an existing dataset, iterate by primary key, dispatch jobs in batches, and gate the rollout behind a config flag so you can enable sync per-model rather than all at once.
Testing. Register a second SQLite in-memory connection in phpunit.xml and assert against it. This package's surface is small enough that a couple of feature tests (assertDatabaseHas('legacy.orders', ...)) will cover most of your risk.
deleteRecord() and soft deletes. The new method deletes from the other database. If your app model uses SoftDeletes, a deleted event fires on soft delete — decide deliberately whether that should hard-delete the legacy row, soft-delete it, or do nothing. Getting this wrong is destructive and hard to reverse.Model::where(...)->delete() and DB::table(...)->delete() fire no model events, so no deleteRecord() call. If you use pruning or cascade deletes, you'll silently accumulate orphans in the legacy DB. Audit every mass-delete path.deleteRecord() will throw on children-first violations. Handle child records explicitly or you'll get partial deletes.deleted model event fires after the row is gone from your DB. If the legacy delete then fails, the retry job has to reconstruct enough state (just an ID and table name) to act — make sure your job payload carries only scalars, not a serialized Eloquent model.created_at/updated_at. Set $timestamps = false on the legacy model or you'll write columns that don't exist.DB::connection('legacy')->enableQueryLog() then ->getQueryLog(), or ->toRawSql() on a builder, is faster than adding Log::info calls. Most issues here are "wrong connection" or "wrong table", not exotic bugs."0.3.0", not ^0.3) and read the changelog before each bump — v0.3.0 itself is purely additive, but that won't hold forever.How can I help you explore Laravel packages today?