How Relaticle CRM uses Tapix for contact imports | 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)

 ProductHow Relaticle CRM uses Tapix for contact imports
================================================

 tapix.dev/blog

  [    Back to blog ](https://tapix.dev/blog) [ Product ](https://tapix.dev/blog/category/product)

How Relaticle CRM uses Tapix for contact imports
================================================

 Manch Minasyan ·  June 30, 2026  · 9 min read

 Relaticle is an open-source CRM built with Laravel and Filament. It has over 1,000 GitHub stars and a growing community of developers who self-host it for their teams. Contact import was the most requested feature from the first month. Users showed up with CSV exports from HubSpot, Salesforce, Google Contacts, Mailchimp, and hand-maintained spreadsheets. Every file was different. Every file needed to work.

This is the story of how Relaticle's contact importer went from a three-week custom build to a single class under 60 lines powered by Tapix.

*Full disclosure: Relaticle is an open-source CRM built by the same developer behind Tapix. The import challenges described here directly inspired Tapix's development.*

[\#](#the-challenge "Permalink")The challenge
---------------------------------------------

CRM users import contacts from everywhere. A marketing team exports 8,000 leads from Mailchimp. A sales rep pastes contacts from a trade show spreadsheet. A founder migrates from HubSpot after canceling their subscription. Each source has its own column naming conventions, its own quirks, and its own mess.

The problems stack up fast:

- **Column names vary wildly.** One export says "First Name", another says "fname", a third says "Given Name". The same data, labeled differently every time.
- **Company relationships are strings, not IDs.** A CSV column says "Acme Corp" but the database needs a foreign key to the `companies` table. If Acme Corp does not exist yet, the importer needs to create it. If it already exists, the importer needs to link to it without creating a duplicate.
- **Status fields use free-text values.** One file says "active", another says "Active", a third says "ACTIVE". Some say "lead" when the system expects "prospect". Free text mapped to a constrained set of options requires explicit choice handling.
- **Currency fields come in every format.** Deal values show up as "$1,200.00", "1200", "1.200,00" (European format), and occasionally "~1200 USD". Parsing these into a consistent numeric value is its own category of problem.
- **Duplicate detection matters.** If a contact with the same email already exists, the import should update the record rather than create a duplicate. But the user needs to see which rows will create new contacts and which will update existing ones before anything is written to the database.

The first version of Relaticle's contact import was a custom build: `fgetcsv` for parsing, a manual column mapping UI, hand-written validation for each field type, direct Eloquent calls in a loop. Over 400 lines spread across a controller, a job, a Livewire component, and several Blade templates. Three weeks of work. Every bug fix in one importer meant checking whether the same bug existed in the company and deal importers.

That experience is what led to Tapix in the first place. The full backstory is in [Why we're building Tapix](/blog/why-we-are-building-tapix).

[\#](#the-solution-contactimporter "Permalink")The solution: ContactImporter
----------------------------------------------------------------------------

When Tapix matured enough to replace the custom build, the entire contact import collapsed into a single class. Here is the full implementation:

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

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

    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 !== '') {
            $model->company_id = Company::firstOrCreate(
                ['name' => $companyName],
            )->id;
        }

        unset($model->company);
    }

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

    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']),
        ]);
    }
}

```

That is the entire importer. No controller code for handling file uploads. No Blade templates for the mapping UI. No job classes for background processing. No validation logic for emails, phone numbers, or currencies. Tapix handles all of it.

[\#](#features-in-action "Permalink")Features in action
-------------------------------------------------------

The ContactImporter uses five distinct Tapix features. Each one replaced custom code that previously lived in a dedicated class or set of helper functions.

### [\#](#smart-column-mapping-with-guess "Permalink")Smart column mapping with guess()

Every field definition includes a `guess()` call with common synonyms. When a user uploads a CSV with "Given Name" as the header, Tapix auto-maps it to `first_name` without manual intervention. The mapping step still lets users override the auto-detection, but in practice the guesses are accurate enough that most users click through without changes.

### [\#](#matchorcreate-for-companies "Permalink")MatchOrCreate for companies

The company field uses `->relationship()` with `MatchBehavior::MatchOrCreate`. When a CSV row says "Acme Corp", Tapix looks for an existing company with that name. If it finds one, it links the contact. If not, it creates the company and links to the new record. The `matchBy: ['name']` parameter tells Tapix which column to use for the lookup.

The `beforeSave` hook handles the actual `firstOrCreate` call. This is intentional. The relationship declaration on the field tells Tapix about the relationship for the UI -- showing users which companies will be created and which already exist during the review step. The hook handles the database write. Two concerns, cleanly separated.

For more on the match-or-create pattern and how it works across different relationship types, see [Importing relational data from CSV files in Laravel](/blog/importing-relational-data-csv-laravel).

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

The `status` field uses `FieldType::Choice` backed by a PHP enum. `ContactStatus::cases()` generates the option list, so the valid values are defined once in the enum and stay in sync with the rest of the application. During import, the mapping step shows users a dropdown of valid statuses. If a CSV value does not match any option, it is flagged during validation and the user can correct it inline before the import runs.

This is a meaningful difference from custom validation. With the old approach, invalid statuses produced a generic error message after the import failed. With Tapix, the user sees the invalid value, sees the list of valid options, and picks the right one -- all before a single row is written to the database. For a deep-dive into how choice field validation and boolean normalization work under the hood, see [Handling boolean and choice fields in CSV imports](/blog/boolean-choice-fields-csv).

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

The `deal_value` field uses `FieldType::Currency`. Behind the scenes, Tapix runs values through `NumberFormat::parse()`, which handles dollar signs, euro signs, thousands separators, decimal commas, and other formatting variations. A column containing "$1,200.00", "1200", and "1.200,00" all parse to the same numeric value. For a full treatment of the parsing logic and edge cases, see [Parsing numbers and currencies from CSV files in Laravel](/blog/csv-number-currency-parsing-laravel).

### [\#](#email-based-matching "Permalink")Email-based matching

The `matchableFields()` method defines how Tapix determines whether a row should create a new contact or update an existing one. `MatchableField::email('email', MatchBehavior::MatchOrCreate)` tells Tapix to look up existing contacts by email. If a match exists, the row updates that contact. If not, a new contact is created.

This runs during the review step, not during execution. Before any data is written, the user sees a clear breakdown: how many rows will create new contacts, how many will update existing ones. They can inspect individual rows, skip ones they do not want, and correct values that look wrong. The import only runs after the user explicitly confirms.

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

The `beforeImport` and `beforeSave` hooks solve a problem specific to multi-user CRMs: every contact needs an owner. `beforeImport` captures the importing user's ID from the `Import` model. `beforeSave` assigns that ID to every contact, ensuring ownership is correct regardless of which queue worker processes the job.

The `prepareForSave` hook cleans up data before it reaches the model -- removing `id` (not mass-assignable) and `company` (handled separately in `beforeSave` via `firstOrCreate`). Without this, the company string would hit the Contact model's `fill()` method and cause an error.

For a full reference of all six hooks and when to use each, see [CSV import lifecycle hooks: running custom logic before, during, and after import](/blog/csv-import-lifecycle-hooks).

[\#](#the-results "Permalink")The results
-----------------------------------------

Replacing the custom build with the Tapix-powered ContactImporter changed three things.

**Code reduced from 400+ lines to under 120.** The importer class is the only file a developer needs to read or modify. The upload UI, column mapping, validation, review table, and background processing are all handled by Tapix. No controller, no Blade template, no dedicated job class.

**Error correction moved from post-import to pre-import.** With the old build, users uploaded a file, waited for it to finish, and saw a list of failed rows. With Tapix, validation happens during the review step. Users see every error before a single row is written, fix values inline, skip bad rows, or cancel entirely. Support tickets about duplicates and missing contacts dropped to near zero.

**New importers take minutes, not weeks.** When Relaticle needed a company importer, it followed the same pattern: extend `BaseImporter`, define fields, set up matchable fields. Done in under an hour. The deal importer took a similar amount of time. The three-week build became a repeatable template.

[\#](#what-this-means-for-your-project "Permalink")What this means for your project
-----------------------------------------------------------------------------------

Relaticle's contact importer is not special. The fields are standard CRM fields. The relationship is a basic BelongsTo. The matching logic is email-based deduplication. What makes it a useful case study is the gap between approaches: 400+ lines across multiple files and three weeks of development versus a single class that a new developer can read in five minutes.

If your Laravel application has a CSV import feature -- or needs one -- the pattern is the same. Define your fields, declare your relationships, set your matching strategy, and let the framework handle the rest.

For a step-by-step tutorial on building this exact importer from scratch, see [Building a contact importer for your CRM](/blog/building-contact-importer-crm). For the broader story behind why Tapix exists, read [Why we're building Tapix](/blog/why-we-are-building-tapix). And for the relational data patterns used in the company field, see [Importing relational data from CSV files in Laravel](/blog/importing-relational-data-csv-laravel).

Ready to replace your custom import code? Check out the [pricing plans](/#pricing) and start building.

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

 [  Product   Jun 16, 2026

 SaaS CSV importers vs self-hosted: which one fits your Laravel app?
---------------------------------------------------------------------

CSVBox, OneSchema, and Flatfile are hosted. Tapix is self-hosted. Here's the tradeoff matrix for choosing between them.

 ](https://tapix.dev/blog/csvbox-vs-self-hosted-import) [  Product   Jun 9, 2026

 Tapix under the hood: how we built a 4-step import wizard
-----------------------------------------------------------

The architecture behind Tapix: database-persisted state, batched per-column validation, immutable value objects, and chunked queue execution.

 ](https://tapix.dev/blog/tapix-under-the-hood-architecture) [  Product   May 8, 2026

 Laravel Excel vs Tapix: choosing the right import tool
--------------------------------------------------------

Laravel Excel and Tapix solve different problems. Here's when to use each -- and why they work well together.

 ](https://tapix.dev/blog/laravel-excel-vs-tapix)

   [ ![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.
