CSV import lifecycle hooks: running custom logic before, during, and after import
CSV import lifecycle hooks: running custom logic before, during, and after import
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
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:
-
beforeValidation -- before the validation batch runs. Receives the
Importmodel. Use it to adjust validation rules, warm caches, or prepare state that the validation jobs will need. -
beforeImport -- before any rows are processed in the execution phase. Receives the
Importmodel. Use it to capture context (the importing user, tenant settings), preload lookup tables, or initialize counters. -
prepareForSave -- per row, before the model is filled. Receives the raw
$dataarray, the$existingmodel (or null for creates), and a mutable$contextarray. Returns the transformed data array. Use it to strip keys, compute derived values, or pass state to later hooks via$context. -
beforeSave -- per row, after
prepareForSavebut 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. -
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.
-
afterImport -- after all rows are processed and the import is marked complete. Receives the
Importmodel 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
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
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
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
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
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.
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
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
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
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
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
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.
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
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. 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 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.
If you want lifecycle hooks, queue-powered execution, and a 4-step wizard UI without building it from scratch, Tapix handles the full pipeline.