Importers
An importer describes one record type: its model, its fields, how its records belong to an owner, who may import them, how a row becomes a record and how an existing one is recognised. Extend RoundlyConsulting\Imports\Importer and register the class:
// config/imports.php
'importers' => [
App\Imports\AssetsImporter::class,
App\Imports\DocumentsImporter::class,
],
// or at runtime, e.g. in a service provider's boot()
use RoundlyConsulting\Imports\Facades\Imports;
Imports::register(AssetsImporter::class);Importers are resolved through the container — constructor injection works — and validated once per process, lazily, on first use.
Methods and defaults
| Method | Required | Default | Notes |
|---|---|---|---|
model(): class-string<Model> | yes | — | Must live on the package tables’ connection — one transaction. |
fields(): list<Field> | yes | — | At least one; names unique, ^[a-z][a-z0-9_]*$. |
ownership(): Ownership | yes | — | Fail closed — see Owners and permissions. |
authorize(?Authenticatable, Model $owner, ImportOperation): bool | yes | — | Asked for View, Upload, Commit and Rollback. |
create(ImportRow, ImportContext): Model | yes | — | Return a saved model inside the owner. Returning an existing record is fine — it is ledgered as linked and never undone. |
update(Model, ImportRow, ImportContext): Model | for Update | throws | Overriding it enables the Update mode. |
key(): string | no | the class name without Importer, snake case (AssetsImporter → assets, TeamMembersImporter → team_members) | ^[a-z][a-z0-9_.-]{0,63}$, unique. Declare it in production — saved mappings and HasImportSource lookups are keyed by it. |
label(): string|array | no | the key as a headline (Team Members) | A translation key, text, or ['en' => …, 'sk' => …]. |
naturalKey(): ?NaturalKey | no | null | No key, no duplicate detection. |
modes(): list<DuplicateMode> | no | Skip, Update (when update() is overridden), CreateNew | — |
defaultMode(): DuplicateMode | no | Skip | Must be one of modes(). |
rollback(Model, ImportContext): RollbackOutcome | no | deletes → Deleted | Return Handled (undone another way) or Kept. |
changeDetection(): ChangeDetection | no | ChangeDetection::checksum() | Or ::updatedAt(), or ::checksum(['name', …]). |
checksumAttributes(Model): array | no | raw attributes minus key, timestamps, deleted_at | What the checksum covers. |
diff(Model, ImportRow): list<FieldChange> | no | mapped, non-blank, non-reference fields vs same-named attributes | The preview of updates — a blank cell is no change. |
formats(): list<SourceFormat> | no | CSV, XLSX | — |
limits(): Limits | no | from config | Limits::fromConfig()->withRows(50_000)->withColumns(200)->withFileSize(20_480)->withCellLength(255) — rows capped at 100,000, columns at 16,384, the cell length clamped to 1 … 1,048,576 and checked per cell (too_long). |
Final helpers on every importer: kind() (ImportKind::Rows), supportsUpdate() (whether update() is overridden), field(string $name) and labelIn(?string $locale).
use RoundlyConsulting\Imports\Definition\{ChangeDetection, Limits};
use RoundlyConsulting\Imports\Enums\{DuplicateMode, SourceFormat};
public function key(): string { return 'assets'; } // declare it in production
public function label(): string|array { return ['en' => 'Assets', 'sk' => 'Majetok']; }
public function modes(): array { return [DuplicateMode::Skip, DuplicateMode::Update]; }
public function defaultMode(): DuplicateMode { return DuplicateMode::Update; }
public function formats(): array { return [SourceFormat::Xlsx]; }
public function limits(): Limits
{
return Limits::fromConfig()->withRows(50_000)->withColumns(200)->withFileSize(20_480)->withCellLength(255);
}
public function changeDetection(): ChangeDetection
{
return ChangeDetection::checksum(['name', 'serial_number']); // or ChangeDetection::updatedAt()
}Declare key() for production importers. Saved mappings and HasImportSource lookups are keyed by it, so with the default, renaming the class renames the key and orphans them. A class name that gives no valid key — an anonymous class, a class named just Importer — is refused naming the class.
Natural keys
use RoundlyConsulting\Imports\Definition\NaturalKey;
public function naturalKey(): ?NaturalKey
{
return NaturalKey::by('asset_tag')->then('serial_number'); // candidates, tried in order
}
NaturalKey::by('first_name', 'last_name')->caseInsensitive(); // one candidate, several fields- Validation comes first: an invalid row never matches or counts as a duplicate.
- A row’s first filled candidate that finds a record is its match. A later candidate finding a different record is conflicting_match; two records for one key is ambiguous_match.
- A row repeating an earlier valid row’s key, or matching the same record, is duplicate_in_file.
- Keys are matched against soft-deleted records too. A live record always wins, but a key only a soft-deleted record holds makes the row invalid (matches_deleted_record, in every mode) — your unique index still covers that record, so a create would fail at commit.
The create and update callbacks
public function create(ImportRow $row, ImportContext $context): Model
{
return Asset::create([...$row->attributes(), ...$context->ownerAttributes()]);
}
public function update(Model $asset, ImportRow $row, ImportContext $context): Model
{
return tap($asset)->update($row->changes());
}ImportRow carries two fill-ready maps. attributes() holds every value, each reference field becoming its storeAs() column with the referenced key (['asset_tag' => 'AST-1', 'vendor_id' => 5]) — use it for create(). changes() is attributes() restricted to mapped fields whose cell was not blank — use it for update().
Blank cells in the Update mode keep the stored value: exports from other tools routinely leave columns blank, and updates are never reverted by undo. update() writing $row->changes() follows that rule, and the default diff() previews no change for a blank cell. To clear values on purpose, write $row->attributes() in update() and override diff() to match.
What callbacks receive
use RoundlyConsulting\Imports\Exceptions\ImportRowException;
public function create(ImportRow $row, ImportContext $context): Model
{
$row->number(); // the spreadsheet row number
$row->get('name'); // the coerced value
$row->has('purchased_on'); // the field is mapped
$row->filledIn('serial_number'); // mapped and not blank
$row->reference('vendor'); // the resolved Vendor model, or null
$row->raw('name'); // the cell as text
$row->cell('name'); // the Cell (type, value)
$row->all(); $row->filled(); $row->only('name'); $row->except('vendor');
$row->attributes(); // ['asset_tag' => 'AST-1', …, 'vendor_id' => 5]
$row->changes(); // attributes() of mapped, non-blank cells only
$context->owner; $context->actor; $context->mode; $context->locale; $context->timezone;
$context->sessionId; $context->batchId;
$context->query(Vendor::class); // a query constrained to the owner
$context->ownerAttributes(); // ['team_id' => 7] for Ownership::column('team_id')
if ($row->get('status') === 'retired') {
throw ImportRowException::because(__('app.imports.retired')); // fails this row only
}
return Asset::create([...$row->attributes(), ...$context->ownerAttributes()]);
}ImportContext exposes owner, actor, importer, sessionId, batchId, mode, locale and timezone (the session’s — the clock the file’s times were read in) as public properties, plus query(string $model, ?Ownership $rule = null) — a query constrained to the owner by the importer’s rule or the one you pass — and ownerAttributes(). Throw ImportRowException::because('…') to fail one row with your own translated message.
The rollback hook
Undo deletes created records unless they changed since. Override rollback() to undo a record another way:
use RoundlyConsulting\Imports\Enums\RollbackOutcome;
public function rollback(Model $record, ImportContext $context): RollbackOutcome
{
if ($record->accepted_at !== null) {
return RollbackOutcome::Kept; // leave it alone
}
$record->revoke(); // undone another way
return RollbackOutcome::Handled; // the default deletes it: RollbackOutcome::Deleted
}Registration rules
Registration refuses, with InvalidImporterDefinitionException naming the importer and the rule:
- a bad key, two importers with one key, no ownership, a model on another connection;
- no fields, bad or duplicate field names, a choice without options, a reference without to() or by();
- natural-key fields that are missing or are references;
- the Update mode without update(), a default mode outside modes(), no formats;
- storeAs() on a field that is not a reference, or a storeAs() column that is not a column name or is another field’s name;
- database presence rules — exists: / unique: strings, Rule::exists() / Rule::unique() — because they bypass the owner scope and could reveal another owner’s data. Use Field::reference() and naturalKey() instead.
A reference that inherits a rule its model cannot carry (team_id on a model without it) throws UnscopedQueryException — declare ->ownedBy() on the field.
Discovery for a UI
Imports::importers()->available($user, $team); // list<ImporterDescriptor> the user may view
Imports::importers()->describe('assets', 'sk'); // fields: label, type, required, options, example, help, aliases, format
Imports::importers()->all(); Imports::importers()->has('assets'); Imports::importers()->find('assets');
Imports::importer('assets')->modes();
Imports::importer('assets')->descriptor('sk');available() lists only the importers whose authorize() allows View. The registry is printed by:
php artisan imports:importers # key, label, kind, fields, modes, formatsOctane and long-running workers
Importer instances are container singletons inside the registry for the life of the process — a queue worker, an Octane worker. Inject nothing request-scoped into an importer’s constructor (the current user, the request, a tenant): it would leak into the next request or job. Everything per import arrives through ImportContext and the ImportRow.
Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.