Back to blog

Building a contact importer for your CRM

Manch Minasyan · · 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

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

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

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:

<?php

declare(strict_types=1);

namespace App\Importers;

use App\Enums\ContactStatus;
use App\Models\Company;
use App\Models\Contact;
use Illuminate\Database\Eloquent\Model;
use Tapix\Core\Data\MatchableField;
use Tapix\Core\Enums\MatchBehavior;
use Tapix\Core\Fields\FieldType;
use Tapix\Core\Fields\ImportField;
use Tapix\Core\Fields\ImportFieldCollection;
use Tapix\Core\Importing\BaseImporter;
use Tapix\Core\Models\Import;

class ContactImporter extends BaseImporter
{
    private int|string|null $importUserId = null;

    public function model(): string
    {
        return Contact::class;
    }

    public function fields(): ImportFieldCollection
    {
        return ImportFieldCollection::make([
            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('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

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

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

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

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

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

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.

#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

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

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 covers the full pattern for context propagation.

#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 eliminates those redundant queries for high-repetition imports.

#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

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

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

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. If your import has more complex relationships (tags, categories, polymorphic links), Importing relational data from CSV files in 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 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.

Almost there — confirm your subscription via email.

Related posts