---
title: "Importers — Imports for Laravel | Roundly"
description: "One class per record type extending Importer — every method and default, natural keys, create and update callbacks, rollback hooks, registration rules."
url: https://roundly-consulting.com/open-source/docs/imports-for-laravel/importers
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Imports for Laravel](https://roundly-consulting.com/open-source/docs/imports-for-laravel.md)

# 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:

```php
// 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).

```php
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

```php
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

```php
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

```php
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:

```php
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

```php
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:

```bash
php artisan imports:importers   # key, label, kind, fields, modes, formats
```

## Octane 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 crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## 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.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/imports-for-laravel.md)
