statamic/importer
Statamic Importer brings external data into Statamic collections from CSV, JSON, XML, and other sources. Map fields, transform values, preview results, and run repeatable imports from the control panel or CLI.
Install with the version floor as a decision, not a formality. The addon is a standard Statamic addon, so install is one line — but pin deliberately:
composer require statamic/importer:^2.3.2
php artisan vendor:publish --tag=statamic-importer
2.3.2 is your floor if you use the Eloquent driver. Prior 2.x releases have a silent failure where related entries are not resolved through the Eloquent driver — imported entries look complete, but the relationship values were never written. Verify your Statamic 4/5, Laravel 10/11/12 and PHP 8.1+ combination against the addon's composer.json before committing a sprint to it.
Where to look first, in this order:
vendor/statamic/importer/src — with 18 stars and zero dependents there is no community knowledge base, so the source is the documentation. Sources/ shows the available drivers (csv, json, xml, eloquent); Destinations/ shows what you can write into.php artisan list importer (or list statamic) — the run command has moved around across 2.x minors. Trust artisan list over any blog post or AI answer.Prerequisites on the Statamic side. Importers write into collections, so the target collection and blueprint must already exist and be file-based (version-controlled, not CP-only) before you run anything. Fields referenced in your mapping must already exist on the blueprint — the importer will not create field definitions for you.
First use case: a five-row fixture, not a five-thousand-row migration. Create an importer class under app/Importers, register it in config, and run it against a hand-written CSV with two or three columns. The shape of a v2 importer looks like this:
namespace App\Importers;
use Statamic\Importer\Importers\Importer;
use Statamic\Importer\Sources\Csv;
use Statamic\Importer\Destinations\Collection;
class ProductsImporter extends Importer
{
public function source(): Csv
{
return Csv::make(storage_path('imports/products.csv'));
}
public function destination(): Collection
{
return Collection::make('products')->blueprint('product');
}
public function mappings(): array
{
return [
'title' => 'name',
'sku' => 'sku',
'price' => 'price',
];
}
}
Exact method names drift between 2.x minors — read the abstract Importer class in your installed version and match it. Then run it from the Control Panel first (upload, preview, map, run). That CP pass is what tells you whether content ops can self-serve imports, or whether you own every run for the life of the project.
One importer class per source shape, not per file. Importers are configuration living in the repo — that is the entire value proposition over a throwaway Artisan command. Treat them like migrations: named for the source system, reviewed in PRs, re-runnable in staging. Parameterise the source (a path, a model, a where clause) rather than hard-coding today's file.
Make idempotency a first-class requirement. Every importer needs a stable, external, unique key — sku, legacy id, a slug — declared on the destination so re-runs update rather than duplicate. Without it you get duplicate entries on the second run, which is the most common way a parallel-run migration strategy falls apart.
Transform inline, but keep transforms dumb. Mapping-level transforms handle the obvious normalisations:
Field::make('slug')
->map('name')
->transform(fn ($value) => Str::slug($value)),
Field::make('date')
->map('published_at')
->transform(fn ($value) => Carbon::parse($value, 'UTC')->toDateString()),
Anything requiring a database lookup, an HTTP call, or another entry to already exist belongs in a second pass, not an inline closure. Transforms run per row; a lookup inside one becomes an N+1 against your legacy database — on a 20k-row import that's the difference between four minutes and forty.
Resolve relations with a preloaded lookup map. Load the map once before the import and close over it:
$categoryMap = Entry::query()
->where('collection', 'categories')
->get()
->pluck('id', 'legacy_id'); // legacy_id => statamic id
Then ->transform(fn ($id) => $categoryMap[$id] ?? null). This is also what makes relation imports re-runnable: when you backfill a previously dropped relation, the same importer produces the same result.
Two-pass imports for anything order-dependent. Categories before products, authors before articles, parents before children. Entries fields need the target entry to exist; an import referencing a not-yet-imported entry writes null silently. Run parents, verify, then run children.
Eloquent driver as a read-only seam. When the source is a legacy relational database, point a dedicated, read-only connection at it rather than adding an export step:
public function source(): Eloquent
{
return Eloquent::make(LegacyProduct::class)
->connection('legacy')
->columns(['id', 'name', 'sku', 'body', 'category_id']);
}
Keep those models slim: no relations unless the importer needs them, $timestamps = false, no global scopes leaking in. Select explicit columns rather than * — a legacy table with forty TEXT columns will blow memory on a chunked import. This is also the pattern that makes repeat imports cheap enough to run as a sync during a parallel-run period instead of a single cutover.
Everything runs through the queue. Imports dispatch queued jobs, which means php artisan queue:work or Horizon must be running where you expect, the worker's --timeout and memory_limit must tolerate your largest batch, and you must be able to observe failures. Don't kick off a large import in the CP on a shared queue without knowing which worker picks it up.
Verify at the relation level, in a test. Row-count tests do not catch the failure class this package is known for:
it('imports products with categories', function () {
$this->artisan('importer:run', ['importer' => 'products']);
$product = Entry::findBy('sku', 'ABC-1');
expect($product)->not->toBeNull()
->and($product->get('categories'))->toHaveCount(2);
});
Assert field values and relation IDs, not just that entries exist.
The Eloquent relation fix is a data change, not a dependency bump. Upgrading to 2.3.2 on a live project leaves previously-imported content still missing its relations. Plan: drain the queue → deploy → re-import affected content with the same idempotent key → diff relations before and after. Retire any manual post-import patch script deliberately — leaving both the workaround and the fixed resolution logic running double-applies corrections and is harder to debug than the original miss.
Silent partial success is the default failure mode. Row counts pass, scalar spot-checks pass, relationships are empty. After every meaningful import, open a known-to-be-related entry in the CP and look at it. Put "spot-check related content" on the content-ops sign-off checklist in those words.
CSV parsing will bite you before the addon does. Empty strings arrive as "", not null — a nullable date field receiving "" errors or writes garbage. Booleans arrive as "1", "true", "Yes", "yes ". Numbers may carry thousands separators. Normalise in transforms and be explicit about the empty-string case.
Slugs collide. If the destination generates slugs from titles, two entries named "About" produce one entry and one unexplained override. Drive slug from the unique key, or supply an explicit slug mapping.
Spike fieldtype coverage, not the install. Bard/Replicator/Markdown fields expect a specific JSON structure; a CSV column of HTML will not land in a Bard field without a transform shaping [{type: 'paragraph', content: [...]}]. Assets need the container, the file on disk, and a matching path — and a source path and a source URL are different fields. Timebox half a day mapping your real blueprint field types against a fixture, and write down what turns out to be unsupported.
Search, static caches and Stache go stale after an import. An import writes entries; it does not reindex anything and it does not invalidate your static site build. Budget the follow-up commands (search index update, static cache clear, Stache warm) into the runbook as part of the import — otherwise the first bug report you get is "the new products don't show up in search."
Multisite: be explicit about the site. Entries land in a specific site, and unset means default. Importing localised content means one run per locale, with the site set on the destination and a per-site unique key.
Don't grow this into a sync engine. The moment you want continuous two-way sync, change-data-capture, or cross-entry computed fields, you're past the package's remit. The cheap, honest exit is a thin custom Artisan command for the 10% the importer can't express — keep the importer for the bulk load and re-runs, and don't accumulate elaborate transform closures until the config file is a program.
Own it. First-party status and an active issue tracker offset the thin adoption, but there is no community knowledge base to lean on when something breaks at 2am. Name an internal owner for the importer definitions and their regression tests before imports become load-bearing for a launch date.
How can I help you explore Laravel packages today?