lexik/fixtures-mapper-bundle
Symfony bundle for mapping fixture data to Doctrine entities, helping you define reusable fixtures and load test data cleanly. It decouples fixture definitions from loading logic, making database seeding simpler and more maintainable.
Do not composer require this package in a Laravel app. It is a Symfony2 Bundle subclass that registers against a Symfony kernel; it will not boot under Laravel's service provider pipeline, and its doctrine/data-fixtures dependencies are EOL. Treat the repo as a reference pattern, not a dependency.
Start by reading three files in the upstream source rather than running anything:
MapperInterface — one method that receives a parsed record array and populates an entity. That single contract is the whole idea you want.Your first use case in Laravel is almost always one of two things:
Minimal Laravel equivalent to scaffold in an afternoon:
// app/Import/Contracts/Mapper.php
interface Mapper
{
/** @param array<string,mixed> $row */
public function map(array $row): \Illuminate\Database\Eloquent\Model;
/** @return string[] Column headers this mapper requires. */
public function requiredColumns(): array;
}
// app/Console/Commands/ImportFixtures.php
class ImportFixtures extends Command
{
protected $signature = 'fixtures:import {file} {--mapper=} {--truncate}';
public function handle(): int
{
$rows = League\Csv\Reader::createFromPath($this->argument('file'))->getRecords();
$mapper = app($this->option('mapper'));
DB::transaction(function () use ($rows, $mapper) {
$this->option('truncate') && $mapper->modelClass()::query()->delete();
foreach ($rows as $row) {
$mapper->map($row);
}
});
return self::SUCCESS;
}
}
That is the day-to-day leverage of this package: the Mapper abstraction and a thin CSV/YAML driver. Everything else (bundle config, Doctrine ObjectManager, the console command wiring) is Symfony glue you will not port.
If all you need is Model::create() from a hardcoded array, skip all of this and use db:seed with a seeder class — it's one file and no abstraction.
One mapper per model, one file per dataset. Mirror the bundle's one-mapper-per-entity rule. CountryMapper, CurrencyMapper, PermissionMapper. Mappers should be pure (array) => Model functions with no DB queries inside; persistence is the command's job. This makes them unit-testable without touching the database.
Two modes, one command. The pattern that pays off day-to-day is separating insert from upsert:
fixtures:import --truncate — wipe and reload, for local dev and CI. Fast, deterministic, safe.fixtures:import --upsert=code — update-or-create by natural key, for staging/prod-adjacent seeding where you cannot delete rows. Use Model::updateOrCreate(['code' => $row['code']], [...]).Keep these behind the same command with a flag so QA has one entry point.
Column registry beats positional parsing. In your mapper, validate headers on entry:
public function map(array $row): Model
{
foreach ($this->requiredColumns() as $col) {
if (! array_key_exists($col, $row)) {
throw new \RuntimeException("Missing column: {$col}");
}
}
// ...
}
Fail on unknown headers too (array_diff(array_keys($row), $this->requiredColumns()) → throw). This is the biggest quality-of-life gain over the original bundle, which silently mapped what it could.
Relation resolution via explicit ordering. The Symfony bundle leaned on Doctrine to resolve associations. In Eloquent you do it yourself: order your mappers topologically and resolve foreign keys by looking up the parent by natural key:
$country = Country::where('iso', $row['country_iso'])->firstOrFail();
$city = City::updateOrCreate(
['slug' => $row['slug']],
['name' => $row['name'], 'country_id' => $country->id],
);
If you have more than two levels of dependency, drive it from a mappers config array where order is the graph:
// config/fixtures.php
return [
'datasets' => [
'geo' => [
App\Import\Mappers\CountryMapper::class,
App\Import\Mappers\RegionMapper::class,
App\Import\Mappers\CityMapper::class,
],
],
];
Then fixtures:import --dataset=geo loads them in order in a single transaction.
Type coercion belongs in the mapper, not the ORM. CSV hands you strings. Eloquent will happily write '1' to an integer column and let MySQL coerce it, which hides bugs. Coerce explicitly:
$isActive = filter_var($row['active'] ?? '0', FILTER_VALIDATE_BOOLEAN);
$launchedAt = Carbon::createFromFormat('Y-m-d', $row['launched_at']);
Add a small Cast helper class in your package — this is the single most reused piece you'll extract from the pattern.
Wrap in a transaction, log row numbers. Always run the import inside DB::transaction(). Catch Throwable, re-throw with the record index included ("Row 47 ({$row['slug']}): ..."), so QA can fix the spreadsheet without paging you.
Wire into your existing seeder. Register your command as a seeder target: DatabaseSeeder calls Artisan::call('fixtures:import', ['file' => database_path('fixtures/countries.csv')]). That way artisan migrate:fresh --seed works, but analysts still edit CSVs.
Use symfony/yaml for YAML, league/csv for CSV. Both are maintained, Symfony-component standalone (no framework dependency), and the yaml parser is what the original bundle used anyway. symfony/yaml has Yaml::parseFile() and flags for safe parsing — see gotchas.
You cannot install this package in Laravel. No amount of composer.json editing will make LexikFixturesMapperBundle boot — the bundle registration is a Symfony kernel concern and there is no analogous pipeline. The correct interpretation of this bundle is "design reference." If you try to require it as a dev dependency, Composer will resolve Doctrine Data Fixtures constraints that will conflict with Laravel 10/11's doctrine/inflector and doctrine/dbal versions.
Archived means no YAML security patches. YAML deserialization is a historically vulnerable surface (object tags leading to unserialization gadgets). The version of symfony/yaml this bundle pinned would fail a modern audit. If you take anything from the repo, take the mapper contract, and use the current symfony/yaml with Yaml::PARSE_OBJECT disabled (default is safe; never enable it).
CSV gotchas that will bite you in week one:
\xEF\xBB\xBF. The first header becomes \u{FEFF}code and your column check fails. Strip it: ltrim($header, "\xEF\xBB\xBF").;. Sniff or accept a --delimiter flag; do not hardcode ,.str_getcsv on a line is not sufficient. Use league/csv — it handles RFC 4180 quoting correctly.mb_convert_encoding($s, 'UTF-8', 'Windows-1252') or reject non-UTF-8 loudly at import time. Prefer rejecting — silent conversion of a customer's data is worse than a clear error.Idempotency is the difference between a useful tool and a footgun. An import that inserts duplicates every run is worse than a PHP seeder. Enforce a natural key on every mapper (iso, slug, code). If a dataset has no natural key, that's a signal it shouldn't be a file-imported dataset — it probably should be a factory.
Truncation ordering. --truncate must delete children before parents, or you'll hit FK constraints — the reverse of the load order. Derive it from the same config/fixtures.php array, reversed. Do it inside the same transaction so a failure mid-load rolls back everything.
Do not use this for production-scale imports. Row-by-row create()/updateOrCreate() is fine to ~10k rows. Beyond that, wrap rows into Model::upsert($chunks, ['natural_key'], ['attr1', 'attr2']) in chunks of 500–1000 and skip the per-model mapper for the bulk path. If you actually need prod data import (not seeding), this whole pattern is the wrong shape — reach for a dedicated importer (Laravel Excel, or a queue-based job per chunk).
Don't build a transformer system until you need it. The bundle's extension point was "custom mappers per entity," which is enough. Resist adding per-column transformers, expression languages, or conditional logic in YAML. The moment you want if:/when: in a fixture file, you wanted a PHP seeder — keep the escape hatch obvious.
Testing. Test mappers as pure functions with a TestCase and no database ($mapper->map($row) → assert attributes on the returned model). Test the command once with RefreshDatabase and a fixture CSV in tests/Fixtures/. This split gives you fast unit tests and a single slow integration test, which is the main day-to-day win over writing seeders directly.
If you do fork or vendor it. MIT permits it, but the extraction effort is realistically 2–3 days for the mapper pattern, and you inherit maintenance on PHP version bumps. In most cases you'll spend less writing ~150 lines of Laravel-native code yourself than porting the Symfony classes. Fork only if the upstream mapping logic (type coercion heuristics, association handling) is genuinely more nuanced than what's described here — for the typical spreadsheet-shaped fixture, it is not.
How can I help you explore Laravel packages today?