tilleuls/migration-bundle
A Symfony bundle that integrates Doctrine database migrations into PHP applications, offering configuration and console commands to create, run, and manage schema changes safely across development, staging, and production environments.
This bundle is a thin framework — it gives you a console command plus LoaderInterface / TransformerInterface (and an abstract Transformer). You write the actual loaders and transformers; the bundle wires them into bin/console coop-tilleuls:migration:migrate.
Minimal steps:
composer require tilleuls/migration-bundle. Because it is archived (last release 2020-11-27) and has effectively zero dependents, verify the constraint resolves against your Symfony/PHP version before committing. If it does not, vendor the source instead of relying on Composer solving.CoopTilleuls\MigrationBundle\CoopTilleulsMigrationBundle (or the bundles.php entry if you are on Symfony 4+; check Resources/config/services.xml for the exact service IDs and tags).Command/MigrationCommand.php — this tells you how a transformer is selected, whether a loader is injected or resolved by name, and which options (--offset, --limit, etc.) actually exist.Loader/LoaderInterface.php — the load() contract and return shape (iterable or array).Transformer/TransformerInterface.php and the abstract Transformer — getName(), supports(), transform().user row → new User entity, persisted with the existing EntityManager).bin/console coop-tilleuls:migration:migrate <transformer-name>First realistic use case: migrate a legacy user table into your new User entity, one transformer per table, running the command per transformer in a deploy or one-off script.
Stream, don't collect. Implement load() as a generator (yield $row) over a DBAL statement or a file handle rather than fetchAll(). This is the single biggest day-to-day decision: it keeps memory bounded and lets you run the same transformer unchanged against small and large datasets.
One transformer per target type, supports() as the router. If a loader emits heterogeneous rows, use supports() to decide which transformer handles a row. Keep getName() stable — it is your CLI identifier and is likely what you pass to the command, so make it something you can type (legacy_user, legacy_order).
Make transform() idempotent. Look up the target by a natural key first (findOneBy(['legacyId' => $row['id']])), then create or update. This lets you re-run a failed migration without truncating and without duplicating rows — essential when the command dies halfway through.
Batch flush and clear manually. The command does not manage your persistence lifecycle for you. Inside transform() (or a small base class you write), accumulate entities and call $em->flush(); $em->clear(); every N rows (100–500 is a reasonable start). Beware: after clear(), any references you held are detached.
Run in prod, non-debug. bin/console --env=prod --no-debug coop-tilleuls:migration:migrate <name>. Debug mode and the profiler inflate memory badly on long migrations.
Test transformers as plain services. Because a transformer is just a class with supports()/transform(), unit-test it with an in-memory or mocked EntityManager. This is much faster than testing through the container and is the main reason to keep transformers small and free of container lookups.
Integration workflow. Chain transformers in a script (migrate legacy_user && migrate legacy_order) after schema/seed is in place. Seed reference data (countries, statuses) with dedicated transformers first, or run them as ordinary fixtures — the migration command should not be your fixture loader.
Composition over inheritance. If the abstract Transformer does not fit, implement TransformerInterface directly; the bundle only cares about the interface contract. Keep a shared abstract base of your own for flush batching, logging, and legacy-ID lookup.
composer.json, and be prepared to fork or inline the ~3 classes this bundle actually provides. Ask whether you need the bundle at all versus copying the command + interfaces into src/.autoconfigure rules. Tag services manually (check Resources/config/services.xml for the exact tag name) and verify with bin/console debug:container --tag=<tag>.MigrationCommand before designing your services; this is where most integration time is lost.supports() returns false, the row is dropped with no error. Log skipped rows in the loader or command wrapper while you are bringing a migration up, otherwise you will discover gaps weeks later.$em->wrapInTransaction() or rely on idempotent upserts. Do not assume the command gives you all-or-nothing semantics — it doesn't.flush() then clear() every N rows; re-fetch anything you need after clear(). Holding onto entity references across a flush is the classic OOM/crash combo here.transform(). Nulls, 0/''/NULL ambiguity, charsets from the old database, timezone-naive datetimes, and enum-like integers. Normalize in the transformer, not in the loader, and write a unit test per quirk.LoaderInterface, or add a Symfony event/messenger dispatch at the end of transform() if you need post-migration side effects. Because the bundle is small, subclassing is usually cheaper than a PR you cannot merge upstream.-v log line per transformed row with the legacy ID and target ID during bring-up; on failures you will want to replay exactly that row.How can I help you explore Laravel packages today?