Back to blog

Migrating from Laravel Excel imports to Tapix

Manch Minasyan · · 12 min read

If your codebase has a class implementing ToModel, this post is for you. Laravel Excel has been the default CSV import package for years, and a typical production importer ends up with six concerns stacked on a single class. It works. Until a user uploads a CSV with "First Name" instead of "first_name", and it silently produces null values.

The interface was designed for developers, not end users. Your importer class knows the exact column order, validation runs silently in the background, and relationship resolution is your problem to solve in the model() method. When you need a user-facing import wizard -- column mapping UI, inline error correction, relationship linking -- Laravel Excel requires you to build all of that yourself.

This guide walks through migrating a real Laravel Excel importer to Tapix, step by step, with side-by-side code so you can see exactly what changes and what stays the same.

#Concept mapping

Before touching code, it helps to see how the two packages map to each other conceptually. Most of what you know from Laravel Excel has a direct equivalent in Tapix -- it is just organized differently.

Laravel Excel Tapix Notes
implements ToModel extends BaseImporter One base class instead of stacking concerns
model(array $row) fields(): ImportFieldCollection Declarative field definitions replace manual array mapping
implements WithHeadingRow Automatic Tapix always parses headers and auto-maps them to fields
implements WithValidation / rules() ->rules([...]) on each ImportField Validation lives on the field definition, not a separate method
implements WithChunkReading Automatic Queue processing with chunked batches is built in
implements WithBatchInserts Automatic Configurable in config/tapix.php, not per-importer
implements ShouldQueue Automatic All Tapix imports run through queued jobs by default
implements SkipsOnFailure Review step Users see and fix errors in the wizard before execution
Manual firstOrCreate in model() ->relationship() on ImportField Declarative relationship linking with configurable match behavior
Excel::import() in controller TapixPlugin or Livewire route UI wizard handles the entire flow

The pattern is consistent: things you implemented manually as concerns or custom logic in Laravel Excel become declarative configuration in Tapix.

#Step 1: Create the importer

Suppose you have a Laravel Excel importer that brings contacts into a CRM. Here is what it typically looks like:

namespace App\Imports;

use App\Models\Contact;
use Maatwebsite\Excel\Concerns\ToModel;
use Maatwebsite\Excel\Concerns\WithHeadingRow;
use Maatwebsite\Excel\Concerns\WithValidation;
use Maatwebsite\Excel\Concerns\WithChunkReading;
use Maatwebsite\Excel\Concerns\WithBatchInserts;
use Illuminate\Contracts\Queue\ShouldQueue;

class ContactsImport implements
    ToModel,
    WithHeadingRow,
    WithValidation,
    WithChunkReading,
    WithBatchInserts,
    ShouldQueue
{
    public function model(array $row): Contact
    {
        return new Contact([
            'first_name' => $row['first_name'],
            'last_name'  => $row['last_name'],
            'email'      => $row['email'],
            'phone'      => $row['phone'],
            'job_title'  => $row['job_title'],
        ]);
    }

    public function rules(): array
    {
        return [
            'first_name' => ['required', 'string', 'max:255'],
            'last_name'  => ['required', 'string', 'max:255'],
            'email'      => ['required', 'email'],
            'phone'      => ['nullable', 'string'],
            'job_title'  => ['nullable', 'string', 'max:255'],
        ];
    }

    public function chunkSize(): int
    {
        return 1000;
    }

    public function batchSize(): int
    {
        return 500;
    }
}

Six interfaces, four methods, and zero UI. The column mapping is implicit -- WithHeadingRow expects the CSV headers to match the array keys exactly. If someone uploads a file with "First Name" instead of "first_name", the import silently produces null values.

Generate the Tapix equivalent:

php artisan make:tapix-importer Contact

This creates app/Importers/ContactImporter.php with the scaffolding already in place. You fill in two methods: model() to declare which Eloquent model receives the data, and fields() to define the importable columns:

namespace App\Importers;

use App\Models\Contact;
use Tapix\Core\Fields\FieldType;
use Tapix\Core\Fields\ImportField;
use Tapix\Core\Fields\ImportFieldCollection;
use Tapix\Core\Importing\BaseImporter;

class ContactImporter extends BaseImporter
{
    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('job_title')
                ->guess(['job title', 'title', 'position', 'role']),
        ]);
    }
}

No WithHeadingRow -- Tapix always parses headers. No WithChunkReading or WithBatchInserts -- queue processing is automatic and configured globally. No WithValidation -- field types like FieldType::Email and FieldType::Phone carry their own default validation rules, and you can add custom rules with ->rules([...]) on any field.

The guess() method is the important addition. When a user uploads a CSV with headers like "First Name", "fname", or "given name", Tapix auto-maps those to the first_name field in the mapping step. No more silent null values from header mismatches.

#Step 2: Define fields with types and validation

In Laravel Excel, validation lives in a separate rules() method that returns a flat array. You reference columns by their heading row key, and the validation runs behind the scenes. If it fails, you either get an exception or silently skip rows (with SkipsOnFailure):

// Laravel Excel: validation in a separate method
public function rules(): array
{
    return [
        'email'      => ['required', 'email'],
        'deal_value' => ['nullable', 'numeric', 'min:0'],
        'status'     => ['nullable', 'in:active,inactive,lead,lost'],
        'phone'      => ['nullable', 'string'],
    ];
}

In Tapix, validation is attached directly to the field definition. The FieldType enum handles common validation automatically -- FieldType::Email adds email validation, FieldType::Currency handles numeric parsing including currency symbols and thousands separators, FieldType::Phone validates phone formats. You only add explicit rules for business-specific constraints:

// Tapix: validation on the field itself
ImportField::make('email')
    ->type(FieldType::Email)
    ->required()
    ->rules(['unique:contacts,email'])
    ->acceptsArbitraryValues(),

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

ImportField::make('status')
    ->type(FieldType::Choice)
    ->options([
        ['label' => 'Active', 'value' => 'active'],
        ['label' => 'Inactive', 'value' => 'inactive'],
        ['label' => 'Lead', 'value' => 'lead'],
        ['label' => 'Lost', 'value' => 'lost'],
    ]),

ImportField::make('phone')
    ->type(FieldType::Phone)
    ->acceptsArbitraryValues(),

The practical difference: when Tapix validates and finds errors, the user sees them inline in the review step. They can correct "john@" to "john@example.com" right there in the wizard, fix just the broken rows, and continue. In Laravel Excel, validation failures either throw an exception or get collected into an array that you have to build a UI for yourself.

The typed field system also handles data parsing. A Currency field automatically parses "$1,234.56" into 1234.56 before insertion. A Date field detects common date formats. You do not need to write parsing logic in your model() method.

#Step 3: Add relationships -- the part Laravel Excel cannot do

This is where the migration gets interesting, because you are adding capability that did not exist before.

In a typical Laravel Excel importer, handling relationships means writing manual lookup logic inside the model() method:

// Laravel Excel: manual relationship resolution
public function model(array $row): Contact
{
    $companyId = null;

    if (! empty($row['company'])) {
        $company = Company::firstOrCreate(
            ['name' => $row['company']],
        );
        $companyId = $company->id;
    }

    return new Contact([
        'first_name'  => $row['first_name'],
        'last_name'   => $row['last_name'],
        'email'       => $row['email'],
        'company_id'  => $companyId,
    ]);
}

This works for simple cases, but it has real limitations. The user has no visibility into what happens -- they do not know which companies will be created versus matched. There is no way to configure the behavior per import. If the company column has typos ("Acme Corp" vs "Acme Corporation"), both get created as separate records. And if you need this pattern for multiple relationship columns, the model() method becomes a wall of firstOrCreate calls.

In Tapix, relationships are declared on the field with configurable match behavior:

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

The relationship() method takes four arguments: the Eloquent relationship name on the model, the related model class, which columns to match against when looking up existing records, and the match behavior. For the design decisions behind when to use each option, see The match-or-create pattern: linking CSV data to existing records. The MatchBehavior enum has three options:

The wizard presents the relationship step to users before execution. They see which companies already exist and which will be created, and they can adjust the match behavior per relationship. This is the kind of UI you would spend days building on top of Laravel Excel.

For importers that need to run custom logic when saving, Tapix provides lifecycle hooks. The prepareForSave method lets you transform data before it hits the database, and beforeSave runs with the model instance right before persistence:

private int|string|null $importUserId = null;

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;
}

Note that auth()->id() returns null inside queue workers because there is no HTTP request. The Import model stores the user_id from the original request, so beforeImport caches it on the importer instance for use in per-row hooks.

These hooks replace the inline logic you would put in Laravel Excel's model() method. The difference is that prepareForSave runs on the raw data array, while beforeSave runs on the Eloquent model -- giving you control at both stages. For a full reference of all six hooks and what each is for, see CSV import lifecycle hooks: running custom logic before, during, and after import.

#Step 4: Register the importer

In Laravel Excel, you call Excel::import() from a controller and build whatever UI you want around it:

// Laravel Excel: manual controller + route
class ImportController extends Controller
{
    public function store(Request $request)
    {
        $request->validate(['file' => 'required|file|mimes:csv,xlsx']);

        Excel::import(new ContactsImport, $request->file('file'));

        return back()->with('success', 'Import started.');
    }
}

With Tapix, you register the importer with your Filament panel or expose it via a standalone Livewire route.

For Filament, register TapixPlugin in your panel provider and list your importers:

use Tapix\Core\Filament\TapixPlugin;
use App\Importers\ContactImporter;

public function panel(Panel $panel): Panel
{
    return $panel
        ->plugins([
            TapixPlugin::make()
                ->importers([
                    ContactImporter::class,
                ]),
        ]);
}

The plugin registers the import wizard pages automatically. Users get a full 4-step wizard: upload, column mapping, review and error correction, and execution with live progress. No additional routes, controllers, or Blade views to create. For plugin auto-discovery, tenant context setup, and configuration options, see Adding a CSV import wizard to your Filament panel.

For standalone Laravel applications without Filament, mount the Livewire wizard component on any route:

// routes/web.php
Route::get('/import/contacts', function () {
    return view('import', ['importer' => 'contact']);
})->name('import.contacts');

Then render the wizard in your Blade template:

<livewire:tapix::import-wizard :importer="$importer" />

The wizard component handles the entire flow -- file upload, mapping, validation, review, and execution.

#What stays the same

Migration is not a full rewrite. Large portions of your existing codebase carry over unchanged:

Your Eloquent models do not change. Tapix creates model instances through your existing relationships. If Contact has a company() BelongsTo relationship today, Tapix uses it directly.

Your validation rules transfer directly. Any rule you pass to Laravel Excel's rules() method works the same in Tapix's ->rules([...]) on an ImportField. Same validation engine underneath.

Your queue configuration carries over. Tapix reads queue settings from config/tapix.php, using whatever driver and connection you already have configured.

Your existing data stays intact. You are replacing how new data enters the system, not modifying what is already there.

The migration is additive. Run both packages side by side during the transition -- keep Laravel Excel for importers that work fine, move to Tapix for the ones that need a user-facing wizard or relationship handling.

#What you gain

After migration, your contacts importer goes from a developer-facing concern stack to a user-facing wizard. Concretely:

Header mismatches become non-issues. The guess() aliases handle the common variations. For anything the heuristic does not catch, the mapping step gives users a UI to correct it before a single row is inserted.

Validation errors become fixable, not fatal. Instead of a Laravel Excel exception or a silent skip, users see exactly which rows failed and why. They fix the values inline and retry without re-uploading the file.

Relationships get visibility. Instead of firstOrCreate running silently in a queue worker, users see which companies already exist and which will be created. They can adjust the behavior before execution.

New importers take minutes. Once you have the ContactImporter pattern down, adding an AccountImporter or ProductImporter is a matter of copying the scaffold and filling in the fields. The queue processing, validation pipeline, and wizard UI are already wired up.

#Where to go from here

If you are evaluating whether to migrate, start with the importer that gets the most user complaints. The one where support tickets mention "the CSV had different column names" or "it created duplicate companies." That importer will show the biggest improvement from the switch.

For more background on the concepts covered here:

Ready to make the switch? See pricing and get started.

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