hugoseigle/symfony-import-export-bundle
A Symfony bundle for adding reusable import and export workflows to your application, with configurable field mappings, validation, and support for common tabular formats. Ideal for moving data between files and Doctrine entities via services or commands.
First, the honest framing: this is a Symfony bundle, not a Composer package you can drop into a Laravel app. There is no service provider, no facade, no config/import-export.php. So "day-to-day leverage" for a Laravel developer splits into two realistic paths:
maatwebsite/excel or league/csv.Minimal steps (Symfony path):
composer require hugoseigle/symfony-import-export-bundle:^2.0
With Flex the bundle registers itself; otherwise add it to config/bundles.php manually. Then read, in this order:
README.md — it is effectively the entire documentation surface, and since 2.1.3 it contains the one thing you must not miss: import() never calls flush().src/ — specifically the service definitions, the ImporterInterface implementation, and any PHP attributes used for column mapping. This is a small package; you can read the whole thing in twenty minutes.config/ inside the bundle, for the default service wiring and any overridable parameters.First use case: pick the dullest entity you own — a Category or Tag with three or four scalar fields — and do an export to CSV first, not an import. Export is read-only and proves the wiring (bundle registered, mapping attributes recognised, service resolvable) without risking data. Once a file lands with the right headers and rows, move to import on a throwaway table — and on that first import, deliberately forget to call flush(). Seeing an empty table with a "successful" result is the fastest way to internalise the 2.1.3 contract.
If you're on the Laravel side, your first move is different: read the attribute mapping in src/ and sketch the equivalent WithHeadingRow + ToModel import in Laravel Excel. Don't try to port the bundle itself.
Attribute-driven mapping on the entity. The package's central idea is that import/export shape lives next to the Doctrine entity, declared with attributes on properties rather than in a separate config file. That's genuinely nice: the mapping travels with the model and reviewers see schema and serialisation together. The trade-off is coupling — your persistence layer now knows about spreadsheet columns. For anything beyond CRUD admin tooling, prefer a dedicated DTO and map to the entity in a handler.
The 2.1.3 persistence contract, stated plainly. ImporterInterface::import() returns an ImportResult, schedules created entities with persist() and deleted ones with remove(), and never calls flush(). The unit of work is yours to complete. The canonical shape:
$result = $importer->import($path);
if ($result->/* validity check */) {
$em->flush();
}
Inspect ImportResult before wiring this — check what it actually exposes (counts, per-row errors, a validity flag) and gate your flush() on that rather than on "the method returned without throwing." The release notes describe keeping "your import validity policy in the application," which is a polite way of saying the package will happily leave a half-valid batch sitting in the identity map for you to commit by accident.
Wrap it in wrapInTransaction. Because the flush boundary moved into your code, the natural companion is Doctrine's own transactional helper:
$result = $em->wrapInTransaction(fn () => $importer->import($path));
Be careful: if you call $em->rollback() manually after a failed import, Doctrine closes the entity manager, and your scheduled persist()/remove() operations are stranded. Either use wrapInTransaction, or roll back and immediately $em->clear() (or resetManager() via the manager registry) before touching the EM again.
Service-injection for programmatic use. Rather than shelling out to a console command, inject the bundle's exporter/importer service into your own controller or command (confirm the exact service ID with bin/console debug:container | grep -i import). This lets you wrap the call in your own authorisation checks and audit logging — the bundle will not do that for you, and now that flush is your responsibility, it's also where you decide not to commit.
The nightly export job. The most common real-world shape: a Symfony console command, scheduled by cron or Symfony Scheduler, that exports a query result to object storage, with the filename carrying the date. Keep the exporter dumb and put naming, retention, and notification in your command. Export flow is unchanged by 2.1.3.
Batched import with explicit boundaries. With flush-once, a 10k-row import is a single unit of work: one identity map, one memory peak, one all-or-nothing commit. For anything sizeable, take control of the loop yourself — import a chunk, flush, $em->clear(), record the offset — and accept that you've traded atomicity for resumability. Do not let a spreadsheet row drive an unbatched persist()-per-row loop and hope a single flush at the end survives contact with a 50 MB file.
Deletes deserve their own pass. remove() being scheduled silently means a "sync the CSV against the table" routine will queue deletions and insertions in the same unit of work. Doctrine resolves commit order for you, but uniqueness constraints evaluated mid-flush can bite. If an import both creates and deletes, run the two phases as separate imports with a flush between them.
Header mapping is your contract. Treat the header row as an API. Pin expected column names, fail loudly on mismatch, and version your templates (users-v2.csv). Silent column reordering is the single most common cause of "the import worked but the data is wrong."
Laravel translation of the pattern: Excel::import(new UsersImport, $path) behind a queued job, ShouldQueue + WithChunkReading, a UsersImportFailed notification, and a failed_import_rows table. Laravel Excel wraps each chunk in a transaction for you — which is exactly the convenience this bundle deliberately withholds, so if you're porting, know which side of that line you want to be on.
A patch release changed persistence behaviour. 2.1.3 alters when writes reach the database, which is not the kind of thing semver patch numbers are supposed to carry. If you were on 2.1.2 and relied on the importer's previous flushing, your imports will now report success and write nothing. The symptom is "the result object looks fine, the table is empty" — check for a missing flush() before you check anything else. This alone justifies pinning the exact version.
Dependency risk is the headline. 17 stars, zero dependents, single maintainer, and a release date that reads as aspirational. Pin the exact version in composer.json, commit composer.lock, and assume you are the maintainer of last resort. Vendor-review src/ before production — at this size, that's cheaper than debugging it later. Have an exit plan (a fifty-line league/csv replacement) documented in the ticket. The 2.1.3 behaviour change is a live example of why.
Doctrine hydration still bypasses your model logic. persist() schedules the entity; it does not run your constructors' intent, prePersist lifecycle callbacks, slug generators, password hashers, or Symfony Validator constraints. Audit every non-trivial setter on the target entity before trusting an import, and validate in your own handler before the entities ever reach the identity map.
Flush-once has teeth. All pending inserts and deletes commit together. A single unique-constraint violation on row 9,412 rolls back everything, and the exception surfaces at flush() — far from the offending row — with no indication of which record caused it. If you need per-row error attribution, that's another argument for chunked imports with a recorded offset.
Memory. Most PHP spreadsheet libraries load the entire workbook into memory. A 20 MB .xlsx can easily consume several hundred MB once expanded, and 2.1.3's single unit of work means the managed entities stay resident until you flush and clear(). Prefer CSV for anything large, cap upload size at the web layer, and test with a deliberately oversized fixture rather than discovering the ceiling in production.
Type coercion is where correctness dies. Excel stores dates as floats, booleans as 0/1 or TRUE/FALSE, and long numeric strings (phone numbers, IBANs, leading-zero codes) as numbers or in scientific notation. Decide explicitly: is an empty cell null or ''? Does "1.234,56" parse as a decimal or fail? Write a fixture file full of hostile values and run it through first.
Encoding and locale. Excel on a European desktop happily exports semicolon-delimited CSV with a UTF-8 BOM and Windows-1252 accents. Strip the BOM, detect the delimiter, and normalise to UTF-8 at the boundary. Round-tripping your own export back through your own import is the cheapest test for this.
CSV formula injection. A value like =cmd|'/c calc'!A1 exported and opened in Excel executes. Prefix cells starting with =, +, -, or @ with a single quote on export. Most small packages don't do this; assume it's yours to add.
Extension points to look for: custom type converters (date/decimal/boolean), a pre-persist hook or event subscriber per row, and Symfony service decoration to override a default converter. The new ImportResult is the natural place to hang post-import validation — but if you need per-row pre-persist hooks and they don't exist, that absence is itself the finding. Fork the bundle into your own src/ at that point rather than fighting it.
Debugging checklist: debug:container for the real service IDs; dump the resolved mapping metadata for one entity before running a real file; assert on ImportResult before flushing; keep a two-row fixture in tests/. With a package this small and this lightly maintained, your test fixture is the only specification you can actually rely on — and as of 2.1.3, one of those tests should explicitly assert that forgetting flush() writes nothing and that calling it writes everything.
How can I help you explore Laravel packages today?