CSV import lifecycle hooks: running custom logic before, during, and after import | 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)

 Best PracticesCSV import lifecycle hooks: running custom logic before, during, and after import
=================================================================================

 tapix.dev/blog

  [    Back to blog ](https://tapix.dev/blog) [ Best Practices ](https://tapix.dev/blog/category/best-practices)

CSV import lifecycle hooks: running custom logic before, during, and after import
=================================================================================

 Manch Minasyan ·  June 23, 2026  · 11 min read

 Most CSV import tools give you a single callback: here is the row, do something with it. That works until you need to set an owner, resolve a relationship, strip keys that do not belong in the database, preload a lookup table, or send a summary email when everything finishes.

Real imports have stages. Data arrives, gets validated, gets transformed, gets persisted, and then something needs to happen after it is persisted. Custom logic belongs at specific points in that pipeline, not crammed into one closure. Tapix addresses this with six lifecycle hooks on the `BaseImporter` class, each firing at a different stage of the import process.

[\#](#the-six-hooks "Permalink")The six hooks
---------------------------------------------

Every importer extends `BaseImporter`, which defines six no-op methods you can override:

```
public function beforeValidation(Import $import): void {}

public function beforeImport(Import $import): void {}

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

    return $data;
}

public function beforeSave(Model $model, array $data): void {}

public function afterSave(Model $model, array $data): void {}

public function afterImport(Import $import): void {}

```

The names follow a consistent pattern: `before*` hooks run before the operation they describe, `after*` hooks run after. Three hooks operate at the import level (once per entire import), and three operate at the row level (once per row). Here is when each one fires in the pipeline:

1. **beforeValidation** -- before the validation batch runs. Receives the `Import` model. Use it to adjust validation rules, warm caches, or prepare state that the validation jobs will need.
2. **beforeImport** -- before any rows are processed in the execution phase. Receives the `Import` model. Use it to capture context (the importing user, tenant settings), preload lookup tables, or initialize counters.
3. **prepareForSave** -- per row, before the model is filled. Receives the raw `$data` array, the `$existing` model (or null for creates), and a mutable `$context` array. Returns the transformed data array. Use it to strip keys, compute derived values, or pass state to later hooks via `$context`.
4. **beforeSave** -- per row, after `prepareForSave` but before `$model->save()`. Receives the model instance and the original (pre-transform) data array. Use it to set attributes that cannot be mass-assigned, resolve foreign keys, or apply conditional logic based on the raw data.
5. **afterSave** -- per row, after the model is saved within the same database transaction. Receives the saved model and the data array. Use it for side effects that depend on the model having a primary key: activity logs, dispatching follow-up jobs, incrementing counters, syncing pivot tables.
6. **afterImport** -- after all rows are processed and the import is marked complete. Receives the `Import` model with final counters (`created_rows`, `updated_rows`, `skipped_rows`, `failed_rows`). Use it to send summary notifications, clean up temporary state, or trigger downstream processes.

The execution order within a single row is: `prepareForSave` then `beforeSave` then model save then `afterSave`. The import-level hooks wrap the entire chunk processing loop: `beforeImport` fires once at the top, `afterImport` fires once at the bottom.

[\#](#import-level-hooks-beforeimport-and-afterimport "Permalink")Import-level hooks: beforeImport and afterImport
------------------------------------------------------------------------------------------------------------------

These two hooks run exactly once per import execution. They bracket the entire row processing loop, which makes them the right place for setup and teardown logic.

### [\#](#beforeimport-capturing-context-across-the-queue-boundary "Permalink")beforeImport: capturing context across the queue boundary

The most common use for `beforeImport` is preserving authentication context. When a user clicks "Start Import" in the browser, the import gets dispatched to the queue. By the time `ExecuteImportJob` runs, `auth()->id()` returns null because there is no HTTP request. The `Import` model stores the `user_id` from the original request, so `beforeImport` can cache it on the importer instance:

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

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

```

This cached value is then available in every per-row hook for the rest of the execution. Without it, you would need to query the `Import` model inside every `beforeSave` call, which means one extra query per row multiplied by potentially thousands of rows.

Other uses for `beforeImport`: preloading lookup tables into memory so you do not query per row, initializing a metrics collector, or validating import-level preconditions (a required default category, an active subscription) so you fail early rather than halfway through.

### [\#](#afterimport-summaries-and-cleanup "Permalink")afterImport: summaries and cleanup

`afterImport` fires after the import status is set to `Completed` and the final row counters are persisted. The `Import` model at this point has accurate `created_rows`, `updated_rows`, `skipped_rows`, and `failed_rows` values:

```
public function afterImport(Import $import): void
{
    $import->user->notify(new ImportCompletedNotification(
        created: $import->created_rows,
        updated: $import->updated_rows,
        failed: $import->failed_rows,
    ));

    Cache::forget("importer_lookup_{$import->id}");
}

```

Sending a Slack notification, emailing a summary, clearing temporary caches, triggering a downstream sync job -- these all belong in `afterImport`. Because it runs after the import is complete, any failure here does not affect the import results. The rows are already persisted.

Note that `afterImport` only fires on successful completion. If the import fails partway through, the `ImportFailed` event is dispatched instead. If you need cleanup logic that runs regardless of outcome, listen for both `ImportCompleted` and `ImportFailed` events.

[\#](#row-level-hooks-beforesave-and-aftersave "Permalink")Row-level hooks: beforeSave and afterSave
----------------------------------------------------------------------------------------------------

These hooks fire for every row that passes validation and is not skipped. They operate inside a database transaction, so if `beforeSave` throws an exception, the row's transaction rolls back and the row is recorded as failed without corrupting previously saved rows.

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

`beforeSave` receives the model instance (either a fresh model for creates or the existing model for updates) and the original data array from the CSV row. This is the place to set attributes that cannot come from the CSV data directly -- computed values, foreign keys from relationship resolution, or ownership fields:

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

```

This is pulled directly from a production contact importer. Three things happen:

First, the `user_id` is set from the value cached in `beforeImport`. Every imported contact gets assigned to the user who started the import, not left as null because the queue worker has no authenticated user.

Second, the company relationship is resolved. The CSV has a plain text "Acme Corp" in a `company` column. The database expects a `company_id` foreign key. `firstOrCreate` bridges that gap: find the company by name, or create it if it does not exist, then set the foreign key on the contact. This is the manual version of the match-or-create pattern -- for the declarative approach with configurable behavior, see [The match-or-create pattern: linking CSV data to existing records](/blog/match-or-create-pattern).

Third, the `company` attribute is unset from the model. Without this, Eloquent would try to save "Acme Corp" as the value of a `company` column on the contacts table, which does not exist. The key was useful for resolving the relationship but must be removed before persistence.

### [\#](#aftersave-activity-logs-and-side-effects "Permalink")afterSave: activity logs and side effects

`afterSave` runs after the model is saved and has a primary key. This makes it the right place for anything that needs a reference to the persisted record:

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

```

The activity log entry records that this specific contact was created or updated via CSV import, with a reference to the user who triggered it. If you use Spatie's Activity Log package, this pattern works directly.

Other common `afterSave` uses: dispatching a job to sync to a third-party CRM, incrementing a counter on a parent record, attaching tags via a many-to-many relationship, or writing to an audit table with the before/after diff.

Keep `afterSave` logic fast. It runs inside the same database transaction as the row save, and it runs for every row. If you need to do something slow (API call, image processing), dispatch a queued job from here rather than doing it inline.

[\#](#prepareforsave-transforming-data-before-the-database "Permalink")prepareForSave: transforming data before the database
----------------------------------------------------------------------------------------------------------------------------

`prepareForSave` sits between the raw CSV data and the model. It receives the data array (keyed by field name with values already parsed by their `FieldType`), the existing model if this is an update, and a mutable context array:

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

    return $data;
}

```

The base implementation strips the `id` key by default. You override it to remove additional keys that should not be persisted as columns, or to transform values.

The `$existing` parameter tells you whether this row is creating a new record or updating an existing one. This enables conditional logic:

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

    if ($existing === null) {
        $data['source'] = 'csv_import';
        $data['imported_at'] = now();
    }

    $context['original_email'] = $data['email'] ?? null;

    return $data;
}

```

New records get a `source` tag and timestamp. Existing records do not have their `source` overwritten. The original email is stashed in `$context` so `afterSave` can use it to check whether the email changed.

The `$context` array passes state between hooks for a single row. It starts as `['tenant_id' => ..., 'creator_id' => ...]` and you can add arbitrary keys. It is a reference -- mutations stick.

[\#](#beforevalidation-preparing-for-the-validation-phase "Permalink")beforeValidation: preparing for the validation phase
--------------------------------------------------------------------------------------------------------------------------

`beforeValidation` runs before the validation batch is dispatched. It receives the `Import` model and fires during the review step, not the execution step. This makes it useful for adjusting validation behavior based on the specific import:

```
public function beforeValidation(Import $import): void
{
    $this->loadCustomValidationRules($import->user_id);
}

```

If your application has per-tenant or per-user validation rules (custom required fields, format requirements, value constraints), `beforeValidation` is where you load them so they are available when `ValidateColumnJob` runs for each column.

This hook is less commonly used than the others because most validation is declarative -- field types, required flags, and options handle the majority of cases. But when you need runtime adjustments, this is the hook for it.

[\#](#the-full-execution-flow "Permalink")The full execution flow
-----------------------------------------------------------------

Here is the complete sequence of hook calls for an import with three rows:

```
beforeImport($import)                    -- once, setup

  Row 1:
    prepareForSave($data, null, $context)  -- transform data
    beforeSave($model, $data)              -- set attributes
    $model->save()                         -- persist
    afterSave($model, $data)            -- side effects

  Row 2:
    prepareForSave($data, $existing, $context)
    beforeSave($model, $data)
    $model->save()
    afterSave($model, $data)

  Row 3:
    prepareForSave($data, null, $context)
    beforeSave($model, $data)
    $model->save()
    afterSave($model, $data)

afterImport($import)                     -- once, cleanup

```

Rows 1 and 3 are creates (`$existing` is null, model is a fresh instance). Row 2 is an update (`$existing` is the matched record). Each row's `prepareForSave` through `afterSave` sequence runs inside a database transaction. If row 2 throws an exception, row 1 is already committed and row 3 will still be attempted.

The chunk processing means `afterSave` for a batch of rows completes before the next chunk is loaded. The `ImportRowProcessed` event fires after each chunk with the current count.

[\#](#what-not-to-put-in-hooks "Permalink")What not to put in hooks
-------------------------------------------------------------------

Hooks are for import-specific logic that varies between importers. A few patterns to avoid:

**Do not modify `ExecuteImportJob` directly.** If you find yourself wanting to change the chunk size, the transaction boundary, or the error handling, use the configuration in `config/tapix.php` instead. The job's execution logic is intentionally not customizable per-importer because it handles queue safety, tenant context, and failure recovery in ways that are easy to break.

**Do not put slow operations in `beforeSave` or `afterSave`.** These run inside a database transaction, once per row. An API call that takes 200ms per row turns a 10,000-row import into a 33-minute import with 10,000 open transactions. Dispatch a queued job from `afterSave` instead. For a full picture of how the queue execution is structured and why per-row latency is so consequential, see [Queue-powered imports: processing 100K rows in Laravel](/blog/queue-powered-imports-100k-rows).

**Do not duplicate what field types already handle.** If you are stripping dollar signs from currency values in `prepareForSave`, you are fighting the `FieldType::Currency` parser that already does this. If you are validating email formats in `beforeSave`, the `FieldType::Email` type already flagged invalid emails during the review step. Use the type system first, hooks second.

**Do not throw exceptions from `afterImport` for recoverable failures.** If sending a notification fails, log it and move on. The import data is already saved. Throwing here marks the import as failed even though every row persisted successfully.

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

This post covered the six lifecycle hooks and when to use each one. For a full walkthrough of building an importer that uses these hooks in practice, see [Building a contact importer for your CRM](/blog/building-contact-importer-crm). For the broader architecture of how the import pipeline works -- upload, mapping, validation, review, and execution -- [Tapix under the hood: architecture of a Laravel import pipeline](/blog/tapix-under-the-hood-architecture) covers the full system. And for the foundational guide to CSV importing approaches in Laravel, start with [The complete guide to CSV imports in Laravel](/blog/complete-guide-csv-imports-laravel).

If you want lifecycle hooks, queue-powered execution, and a 4-step wizard UI without building it from scratch, [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
-------------

 [  Best Practices   Jun 5, 2026

 CSV import error recovery: from silent failures to user-friendly correction
-----------------------------------------------------------------------------

The spectrum from silent failure to inline correction. Here's how to store, display, and resolve import errors without losing data.

 ](https://tapix.dev/blog/csv-import-error-recovery) [  Best Practices   Jun 2, 2026

 The match-or-create pattern: linking CSV data to existing records
-------------------------------------------------------------------

Three behaviors for handling CSV references to existing data: match only, match or create, and always create. Here's when to use each.

 ](https://tapix.dev/blog/match-or-create-pattern) [  Best Practices   May 26, 2026

 Intra-import deduplication: preventing duplicate records during CSV import
----------------------------------------------------------------------------

500 rows reference 'Acme Corp'. Without deduplication, you get 500 company records. Here's the normalized-key cache pattern that prevents it.

 ](https://tapix.dev/blog/intra-import-deduplication)

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