Building a contact importer for your CRM | Tapix                  [ ![Tapix](/img/tapix-logo-light.svg) ![Tapix](/img/tapix-logo-dark.svg) ](https://tapix.dev) [Features](https://tapix.dev#features) [Pricing](https://tapix.dev#pricing) [Docs](https://docs.tapix.dev) [Blog](https://tapix.dev/blog)

   Try Demo  [ Get Tapix from $99](https://tapix.dev#pricing)

  [Features](https://tapix.dev#features) [Pricing](https://tapix.dev#pricing) [Docs](https://docs.tapix.dev) [Blog](https://tapix.dev/blog)   Try Demo  [ Get Tapix from $99](https://tapix.dev#pricing)

   ![Tapix](https://tapix.dev/img/tapix-logo-light.svg)

 TutorialsBuilding a contact importer for your CRM
========================================

 tapix.dev/blog

  [    Back to blog ](https://tapix.dev/blog) [ Tutorials ](https://tapix.dev/blog/category/tutorials)

Building a contact importer for your CRM
========================================

 Manch Minasyan ·  May 29, 2026  · 2 min read

 A sales rep exports 15,000 contacts from HubSpot and sends you a CSV. The column header is "First Name." Your field key is `first_name`. That is the easy part.

The harder part: the "Company" column contains plain text like "Acme Corp", but your database has a `companies` table and a `company_id` foreign key. The "Status" column has values like "lead", "active", and "INACTIVE" -- mixed case, no guaranteed correspondence to your enum. The "Deal Value" column has `$1,234.56` on US exports and `1.234,56` on European ones. Some rows have email addresses that already exist in your contacts table; others are new. And the CSV from Zendesk uses "fname" while the one from Google Sheets uses "First Name."

This tutorial walks through building a complete contact importer for a Laravel CRM using Tapix. By the end, you will have an importer that handles names, emails, phone numbers, company relationships, status choice fields, and deal value currencies -- with match-or-create logic that updates existing contacts by email and creates new ones when no match exists.

[\#](#the-use-case "Permalink")The use case
-------------------------------------------

A CRM's contact import encounters these scenarios in practice:

- A sales team migrating 15,000 contacts from HubSpot via CSV export
- A marketing team uploading a conference attendee list from a Google Sheet
- A support team importing contacts from a Zendesk export with different column names
- A founder pasting contacts from a personal spreadsheet with inconsistent formatting

Each scenario produces a CSV with different column headers, different data quality, and different expectations about what happens when a contact already exists in the system.

[\#](#creating-the-importer "Permalink")Creating the importer
-------------------------------------------------------------

Start by generating the importer class:

```
php artisan make:tapix-importer Contact

```

This creates `app/Importers/ContactImporter.php` with a skeleton class extending `BaseImporter`. The skeleton gives you the two methods every importer needs: `model()` to declare the target Eloquent model, and `fields()` to define the importable columns.

Here is the complete importer we are going to build, shown upfront so you can see the full picture before we walk through each piece:

```
required()
                ->guess(['first name', 'first', 'given name', 'fname']),

            ImportField::make('last_name')
                ->required()
                ->guess(['last name', 'last', 'surname', 'family name', 'lname']),

            ImportField::make('email')
                ->type(FieldType::Email)
                ->acceptsArbitraryValues()
                ->guess(['email', 'email address', 'e-mail']),

            ImportField::make('phone')
                ->type(FieldType::Phone)
                ->acceptsArbitraryValues()
                ->guess(['phone', 'phone number', 'telephone', 'mobile', 'cell']),

            ImportField::make('company')
                ->label('Company')
                ->guess(['company', 'company name', 'organization', 'org'])
                ->relationship(
                    name: 'company',
                    model: Company::class,
                    matchBy: ['name'],
                    behavior: MatchBehavior::MatchOrCreate,
                ),

            ImportField::make('job_title')
                ->guess(['job title', 'title', 'position', 'role']),

            ImportField::make('status')
                ->type(FieldType::Choice)
                ->options(
                    array_map(
                        fn (ContactStatus $status): array => [
                            'label' => $status->label(),
                            'value' => $status->value,
                        ],
                        ContactStatus::cases(),
                    ),
                ),

            ImportField::make('deal_value')
                ->type(FieldType::Currency)
                ->guess(['deal value', 'deal', 'value', 'revenue', 'budget', 'amount']),
        ]);
    }

    public function matchableFields(): array
    {
        return [
            MatchableField::id(),
            MatchableField::email('email', MatchBehavior::MatchOrCreate),
        ];
    }

    public function beforeImport(Import $import): void
    {
        $this->importUserId = $import->user_id;
    }

    public function prepareForSave(array $data, ?Model $existing, array &$context): array
    {
        unset($data['id'], $data['company']);

        return $data;
    }

    public function beforeSave(Model $model, array $data): void
    {
        $model->user_id = $this->importUserId;

        $companyName = $data['company'] ?? null;

        if ($companyName !== null && $companyName !== '') {
            // The company field has .relationship() defined above, which handles match-or-create
            // declaratively. This manual firstOrCreate is the explicit fallback for the beforeSave
            // hook path, where we need the resolved company_id set directly on the model before
            // Eloquent persists it. Both paths ultimately call the same underlying logic.
            $model->company_id = Company::firstOrCreate(
                ['name' => $companyName],
            )->id;
        }

        unset($model->company);
    }

    public function afterSave(Model $model, array $data): void
    {
        // Requires spatie/laravel-activitylog. Install with: composer require spatie/laravel-activitylog
        activity()
            ->performedOn($model)
            ->causedBy($this->importUserId)
            ->log('Contact imported from CSV');
    }

    // resolveTenantId() is called by the TenantAware job trait to scope this import's
    // database queries to the correct tenant. Returns the user ID as a fallback when
    // no dedicated tenant resolver is configured.
    public function resolveTenantId(): int|string|null
    {
        return $this->importUserId ?? auth()->id();
    }
}

```

Now let's walk through each section.

[\#](#defining-the-fields "Permalink")Defining the fields
---------------------------------------------------------

The `fields()` method returns an `ImportFieldCollection` -- an ordered list of `ImportField` instances that declare every column the importer knows about. Each field gets a key (matching the database column), a display type, optional validation, and guess aliases for auto-mapping.

### [\#](#names-and-text-fields "Permalink")Names and text fields

```
ImportField::make('first_name')
    ->required()
    ->guess(['first name', 'first', 'given name', 'fname']),

ImportField::make('last_name')
    ->required()
    ->guess(['last name', 'last', 'surname', 'family name', 'lname']),

ImportField::make('job_title')
    ->guess(['job title', 'title', 'position', 'role']),

```

The `make()` factory creates a field keyed to the database column name and auto-generates a display label from it ("first\_name" becomes "First name"). The `required()` method marks the field as mandatory -- rows missing a first or last name will fail validation at the review step.

The `guess()` method is where auto-mapping happens. When a user uploads a CSV, Tapix reads the header row and tries to match each column to a field. The header "First Name" gets normalized (lowercased, spaces treated as interchangeable with underscores and dashes), then compared against the field key and its guess aliases. A CSV with a "fname" column auto-maps to `first_name` without the user touching anything.

For text fields like `job_title`, no explicit `type()` call is needed. The default is `FieldType::Text`, which means the value passes through as a plain string with no parsing or transformation.

### [\#](#email-and-phone "Permalink")Email and phone

```
ImportField::make('email')
    ->type(FieldType::Email)
    ->acceptsArbitraryValues()
    ->guess(['email', 'email address', 'e-mail']),

ImportField::make('phone')
    ->type(FieldType::Phone)
    ->acceptsArbitraryValues()
    ->guess(['phone', 'phone number', 'telephone', 'mobile', 'cell']),

```

Setting `type(FieldType::Email)` does two things. First, it applies the `email` validation rule automatically -- any value that is not a valid email address gets flagged during the review step. Second, it sets the correct icon in the mapping UI so users can visually confirm which field they are mapping to.

`FieldType::Phone` works similarly, applying phone number validation via the `phone:AUTO` rule which detects the phone format automatically.

The `acceptsArbitraryValues()` method marks this field as a free-text input in the mapping UI. This matters because `FieldType::Choice` renders a constrained dropdown of known options. Email and phone fields are not choice fields, but without `acceptsArbitraryValues()` the system cannot infer the rendering mode and defaults conservatively. The method explicitly opts the field into free-text entry.

### [\#](#company-relationship "Permalink")Company relationship

```
ImportField::make('company')
    ->label('Company')
    ->guess(['company', 'company name', 'organization', 'org'])
    ->relationship(
        name: 'company',
        model: Company::class,
        matchBy: ['name'],
        behavior: MatchBehavior::MatchOrCreate,
    ),

```

This is where flat CSV data meets relational database structure. The CSV has a plain text "Company" column with values like "Acme Corp". The database has a `companies` table and a `company_id` foreign key on the contacts table.

The `relationship()` method tells Tapix how to bridge that gap. The `name` parameter is the Eloquent relationship method on the Contact model. The `model` parameter is the related model class. The `matchBy` array specifies which column on the related model to compare against -- here, the company's `name` column.

The `behavior` parameter controls what happens when a match is not found. `MatchBehavior::MatchOrCreate` means: look for an existing company by name, and if none exists, create one. The other options are `MatchBehavior::MatchOnly` (skip the row if no match) and `MatchBehavior::Create` (always create a new record, never look up).

The actual foreign key resolution happens in the `beforeSave` hook, which we will cover in the lifecycle hooks section.

### [\#](#choice-fields "Permalink")Choice fields

```
ImportField::make('status')
    ->type(FieldType::Choice)
    ->options(
        array_map(
            fn (ContactStatus $status): array => [
                'label' => $status->label(),
                'value' => $status->value,
            ],
            ContactStatus::cases(),
        ),
    ),

```

Status fields in a CRM typically have a fixed set of allowed values: Active, Inactive, Lead. The `FieldType::Choice` type tells Tapix to present these as a constrained list. The `options()` method accepts an array of label/value pairs.

Driving options from a PHP enum keeps the importer in sync with the rest of the application. When you add a new status to `ContactStatus`, the importer picks it up automatically. During import, if a CSV row has a status value that does not match any option, it gets flagged at the review step so the user can correct it before processing.

### [\#](#currency-parsing "Permalink")Currency parsing

```
ImportField::make('deal_value')
    ->type(FieldType::Currency)
    ->guess(['deal value', 'deal', 'value', 'revenue', 'budget', 'amount']),

```

The `FieldType::Currency` type handles the mess of number formats that CSV files contain. A deal value column might have `$1,234.56` from a US export, `1.234,56` from a European one, or just `1234.56` with no formatting at all. Tapix's `NumberFormat::parse()` handles currency symbol stripping, thousand separator detection, and decimal normalization. The parsed value arrives in `prepareForSave` as a clean PHP float ready for database insertion.

For more detail on how number and currency parsing works under the hood, see [Parsing numbers and currencies from CSV files in Laravel](/blog/csv-number-currency-parsing-laravel).

[\#](#matching-update-existing-or-create-new "Permalink")Matching: update existing or create new
------------------------------------------------------------------------------------------------

The `matchableFields()` method defines how Tapix decides whether an imported row should update an existing contact or create a new one:

```
public function matchableFields(): array
{
    return [
        MatchableField::id(),
        MatchableField::email('email', MatchBehavior::MatchOrCreate),
    ];
}

```

Match fields are evaluated in priority order. `MatchableField::id()` has a priority of 100 -- if the CSV includes an `id` column with existing record IDs, those take precedence. Its behavior is `MatchBehavior::MatchOnly`, meaning a provided ID that does not match an existing record will be skipped rather than creating a new contact with a specified ID.

`MatchableField::email()` has a priority of 90 and uses `MatchBehavior::MatchOrCreate`. When the CSV does not include record IDs (most common scenario), Tapix falls back to email matching. If a row's email matches an existing contact, that contact gets updated with the new data. If no match exists, a new contact is created.

This is the behavior most CRM users expect: "Update my existing contacts if they are in there, and add the new ones." The match resolution runs as a dedicated `ResolveMatchesJob` before the execution phase, so users can see which rows will create and which will update before committing the import.

[\#](#lifecycle-hooks "Permalink")Lifecycle hooks
-------------------------------------------------

`BaseImporter` provides five lifecycle hooks that let you inject logic at specific points in the import pipeline. The contact importer uses three of them.

### [\#](#beforeimport-capturing-context "Permalink")beforeImport: capturing context

```
public function beforeImport(Import $import): void
{
    $this->importUserId = $import->user_id;
}

```

The `beforeImport` hook runs once before any rows are processed. Here it captures the user who started the import. This is necessary because the execution job runs on the queue, where `auth()->id()` returns null. By caching the user ID from the `Import` model (which was created during the upload step while the user was still authenticated), the importer preserves ownership context across the queue boundary. This is the same problem multi-tenant imports face when scoping queries across queue workers -- [Multi-tenant CSV imports in Laravel](/blog/multi-tenant-csv-imports-laravel) covers the full pattern for context propagation.

### [\#](#beforesave-setting-ownership-and-resolving-relationships "Permalink")beforeSave: setting ownership and resolving relationships

The `beforeSave` hook runs for every row, right before the model is persisted. It receives the model instance and the raw data array:

```
public function beforeSave(Model $model, array $data): void
{
    $model->user_id = $this->importUserId;

    $companyName = $data['company'] ?? null;

    if ($companyName !== null && $companyName !== '') {
        $model->company_id = Company::firstOrCreate(
            ['name' => $companyName],
        )->id;
    }

    unset($model->company);
}

```

Two things happen here. First, every imported contact gets assigned to the user who initiated the import. Second, the company relationship is resolved: if the CSV row has a company name, `firstOrCreate` finds or creates the company record and sets the foreign key. The final `unset` removes the `company` key from the model's attributes so Eloquent does not try to save it as a column (since `company` is a relationship method, not a database column).

One note on `firstOrCreate` at scale: if 300 rows all reference "Acme Corp", this makes 300 database round-trips where only the first one does real work. The normalized-key cache pattern described in [Intra-import deduplication: preventing duplicate records during CSV import](/blog/intra-import-deduplication) eliminates those redundant queries for high-repetition imports.

### [\#](#aftersave-activity-logging "Permalink")afterSave: activity logging

```
public function afterSave(Model $model, array $data): void
{
    activity()
        ->performedOn($model)
        ->causedBy($this->importUserId)
        ->log('Contact imported from CSV');
}

```

The `afterSave` hook runs after each row is persisted. This is the right place for side effects that depend on the saved model: activity logs, notifications, cache invalidation, webhook dispatches. Here we log an activity entry so the CRM's audit trail shows that the contact was created or updated via import rather than manual entry. The `activity()` helper requires `spatie/laravel-activitylog` -- install it with `composer require spatie/laravel-activitylog` and publish its migrations before using this pattern.

[\#](#prepareforsave-cleaning-the-data-array "Permalink")prepareForSave: cleaning the data array
------------------------------------------------------------------------------------------------

```
public function prepareForSave(array $data, ?Model $existing, array &$context): array
{
    unset($data['id'], $data['company']);

    return $data;
}

```

The `prepareForSave` method runs before `beforeSave` and transforms the raw data array. The `$existing` parameter is the matched model when updating, or null when creating. The `$context` array is a mutable reference that persists across the processing of a single row -- useful for passing state between `prepareForSave` and later hooks.

Here we strip two keys. The `id` key is removed because it was only needed for matching (to find the existing record) and should not be passed to `fill()` or `create()` -- you do not want an import overwriting primary keys. The `company` key is removed because it contains a raw company name string, not a valid value for any column on the contacts table. The actual company resolution happens in `beforeSave` via `firstOrCreate`.

[\#](#testing-with-factories "Permalink")Testing with factories
---------------------------------------------------------------

Test the importer the same way you test any other part of your Laravel application. Use factories to set up the import and row records, then assert the outcomes:

```
use App\Importers\ContactImporter;
use App\Models\Company;
use App\Models\Contact;
use Tapix\Core\Models\Import;
use Tapix\Core\Models\ImportRow;

it('creates a contact with a new company', function () {
    $import = Import::factory()
        ->for($this->user)
        ->create(['importer' => ContactImporter::class]);

    ImportRow::factory()
        ->for($import)
        ->create([
            'raw_data' => [
                'first_name' => 'Jane',
                'last_name' => 'Doe',
                'email' => 'jane@example.com',
                'company' => 'Acme Corp',
                'status' => 'lead',
                'deal_value' => '5000',
            ],
        ]);

    // Execute the import...

    expect(Contact::where('email', 'jane@example.com')->exists())->toBeTrue();
    expect(Company::where('name', 'Acme Corp')->exists())->toBeTrue();

    $contact = Contact::where('email', 'jane@example.com')->first();
    expect($contact->company->name)->toBe('Acme Corp');
    expect($contact->status->value)->toBe('lead');
});

it('updates an existing contact matched by email', function () {
    $existing = Contact::factory()->create([
        'email' => 'jane@example.com',
        'first_name' => 'Jane',
        'last_name' => 'Smith',
    ]);

    $import = Import::factory()
        ->for($this->user)
        ->create(['importer' => ContactImporter::class]);

    ImportRow::factory()
        ->for($import)
        ->create([
            'raw_data' => [
                'first_name' => 'Jane',
                'last_name' => 'Doe',
                'email' => 'jane@example.com',
            ],
        ]);

    // Execute the import...

    expect(Contact::where('email', 'jane@example.com')->count())->toBe(1);
    expect($existing->fresh()->last_name)->toBe('Doe');
});

```

Test both the create and update paths. The create path exercises company resolution and field type parsing. The update path proves that email matching works and that existing records get updated rather than duplicated. For currency fields, test with formatted values like `$1,234.56` to confirm the `FieldType::Currency` parser strips the symbol and normalizes the number before insertion.

[\#](#where-to-go-from-here "Permalink")Where to go from here
-------------------------------------------------------------

This tutorial covered the core building blocks of a contact importer: field definitions with type-safe parsing, relationship resolution, match-or-create logic, and lifecycle hooks for ownership and audit trails. Every CRM import is a variation on these same patterns.

For the broader picture of CSV importing in Laravel -- including raw PHP approaches, Laravel Excel, and Filament's built-in action -- read [The complete guide to CSV imports in Laravel](/blog/complete-guide-csv-imports-laravel). If your import has more complex relationships (tags, categories, polymorphic links), [Importing relational data from CSV files in Laravel](/blog/importing-relational-data-csv-laravel) goes deeper on the relationship side. For the specifics of number and currency format handling, [Parsing numbers and currencies from CSV files in Laravel](/blog/csv-number-currency-parsing-laravel) covers the edge cases in detail.

If you are building a contact import and want the wizard UI, auto-mapping, inline validation, and queue processing without wiring it all together yourself, [Tapix](/) handles the full pipeline.

 ### Enjoyed this post?

Get notified when we publish new articles about Laravel imports and data handling.

  Email address   Subscribe

Almost there — confirm your subscription via email.

 Related posts
-------------

 [  Tutorials   May 22, 2026

 Parsing numbers and currencies from CSV files in Laravel
----------------------------------------------------------

1,234.56 or 1.234,56? US or European? Here's how to parse numeric and currency values from CSV files without data corruption.

 ](https://tapix.dev/blog/csv-number-currency-parsing-laravel) [  Tutorials   May 19, 2026

 Multi-tenant CSV imports in Laravel
-------------------------------------

Tenant context disappears in queue jobs. Here's how to preserve it across the entire import pipeline -- from upload to entity resolution.

 ](https://tapix.dev/blog/multi-tenant-csv-imports-laravel) [  Tutorials   May 12, 2026

 Auto-detecting CSV column types in Laravel
--------------------------------------------

From emails to currencies, dates to booleans -- how to infer column types from CSV sample values and configure format-specific parsing.

 ](https://tapix.dev/blog/auto-detecting-csv-column-types)

   [ ![Tapix](/img/tapix-logo-light.svg) ![Tapix](/img/tapix-logo-dark.svg) ](https://tapix.dev)CSV and Excel import wizard for Laravel.

  Product [Pricing](https://tapix.dev#pricing) [Docs](https://docs.tapix.dev) [Blog](https://tapix.dev/blog) [Contact](mailto:hello@tapix.dev)

 Compare [vs Laravel Excel](https://tapix.dev/vs/laravel-excel) [vs Filament Import](https://tapix.dev/vs/filament-import)

 Legal [Privacy](https://tapix.dev/privacy-policy) [Terms](https://tapix.dev/terms-of-service)

© 2026 Tapix. All rights reserved.
