CSV import error recovery: from silent failures to user-friendly correction
CSV import error recovery: from silent failures to user-friendly correction
Every CSV import has errors. The question that determines your user's experience is not whether errors happen, but what your system does when they do. The range of possible answers spans from genuinely harmful to genuinely useful, and most Laravel applications sit somewhere on the harmful end without realizing it.
This post walks through the error recovery spectrum, explains why inline correction is the only approach that works for non-technical users, and shows the data model that makes it possible: immutable raw data, a corrections JSON overlay, per-field skip tracking, and a failed row safety net for runtime exceptions that slip past validation.
#The error recovery spectrum
There are four distinct approaches to handling bad CSV data. They sit on a spectrum from worst to best, measured by one thing: how much data does the user lose?
Silent failure is the bottom of the spectrum. The import runs, bad rows are quietly dropped, and the user gets a success message. "Imported 8,247 of 10,000 rows." Where did the other 1,753 go? Nobody knows. Nobody notices until three weeks later when someone asks why the Acme Corp record is missing. This is not error handling. It is data loss with a green checkmark on top.
Log file export is a step up. The import finishes, bad rows are collected into a downloadable CSV or written to a log, and the user gets a summary. "1,753 rows failed. Download the error report." The user now knows what failed. But fixing it means opening the CSV in a spreadsheet editor, hunting for rows by line number, making corrections blind to your application's validation rules, and re-uploading. The second upload has no memory of the column mapping, relationship resolution, or match configuration from the first pass. Context is lost. For files with relationship columns ("Company", "Department"), the re-upload is effectively starting from scratch.
CSV download of failed rows is the most common pattern in Laravel packages, including Filament's built-in Import Action. It is better than a log file because the download contains only the failed rows, not the entire file. But the fundamental problem remains: the user leaves your application to fix data in a tool that has no knowledge of your validation rules, your database schema, or your relationship model. And between the first import and the re-upload, database state may have changed. A company name that matched on the first pass might not match anymore if someone edited the record.
Inline correction is the top of the spectrum. Errors are shown inside your application, in context, with the original value visible and an input field for the correction. The user fixes the value, the system re-validates immediately, and the correction is stored alongside the original without modifying it. No context switching. No re-upload. No lost mapping configuration. No stale database state.
The gap between "CSV download" and "inline correction" looks small on paper. In practice, it is the difference between a 15-minute frustration loop and a 2-minute review step.
#Why inline correction matters for non-technical users
Developers underestimate how hostile a "download failed rows CSV" workflow is for someone who is not a developer. The person uploading the CSV is usually an operations manager, a data entry specialist, or an account manager. They know their data. They do not know your database schema.
When you hand them a CSV of failed rows with error messages like "The salary field must be a number" or "The hired_at field does not match the format Y-m-d," they have to translate between your application's internal language and the spreadsheet in front of them. Which column is "salary"? They mapped it from a column called "Annual Compensation" in their original file. What is "Y-m-d"? They entered dates as "15/04/2026" because that is how dates work in their country.
Inline correction eliminates this translation layer entirely. The user sees the original value they typed, the error message explaining what is wrong, and an input field where they type the corrected value. The system re-validates on the spot. Green means fixed. Red means try again. No spreadsheet. No re-upload. No guessing.
For relationship fields -- a "Company" column that needs to match existing database records -- inline correction is even more important. The review UI can show the user which companies matched and which did not, and let them pick from existing records or type a corrected name. A CSV download cannot do this. The user would need to know the exact spelling of every company name in your database to fix the CSV by hand.
#The correction data model
The inline correction flow depends on a specific data model: raw data is immutable, corrections are stored as a separate overlay, and the system merges them at execution time. This separation is the architectural decision that makes everything else work.
#Raw data never changes
When a CSV is uploaded, every row is parsed and stored in the database with its original values in a raw_data JSON column. This column is never modified after initial insert. No matter what the user corrects, edits, or skips, the original CSV values are always recoverable.
This matters for auditability. If a user corrects a salary from "$1.234,56" to "1234.56", you can trace exactly what the original file contained and what the user changed. It also matters for undo -- the user can revert a correction at any time and the original value reappears.
#Corrections as a JSON overlay
When the user types a corrected value, it is stored in a separate corrections JSON column on the same row. The keys are column identifiers, the values are the corrected data:
// ImportRow model -- simplified view of the correction flow
// raw_data: {"email": "not-an-email", "salary": "$1.234,56"}
// corrections: {"email": "john@acme.com"}
// validation: {"salary": "The salary field must be a number."}
public function getFinalValue(string $column): mixed
{
if ($this->isValueSkipped($column)) {
return null;
}
if ($this->hasValidationError($column)) {
return null;
}
if ($this->corrections?->has($column)) {
return $this->corrections->get($column);
}
return $this->raw_data->get($column);
}
The getFinalValue method encodes the priority chain: skipped fields return null, unresolved errors return null, corrections take precedence over raw data, and raw data is the fallback. This method is called during execution for every field of every row. It is the single point where raw data and corrections merge.
The getFinalData method returns the full merged row for execution:
public function getFinalData(): array
{
return $this->raw_data->merge($this->corrections ?? [])->all();
}
This is a shallow merge. Corrections overwrite raw values for the columns they cover. Columns without corrections pass through unchanged.
#Re-validation on correction
When the user submits a corrected value, the system does not blindly accept it. The correction is validated against the same rules that flagged the original value. If the correction passes, the validation error for that column is cleared. If the correction is also invalid, the error message updates to reflect the new problem.
This happens in the review step's updateMappedValue method. The flow is: receive the new value, validate it against the column's type and rules, store the correction in the corrections JSON, and update the validation JSON to either clear the error or set a new one. The user sees the result immediately -- the cell turns green or stays red with an updated message.
The critical detail: corrections are applied to all rows sharing the same raw value. If 200 rows have the same misspelled company name, correcting it once fixes all 200. The review UI groups by unique value and shows a count, so the user knows the impact of each correction.
#Skipping values: not blank, not corrected, deliberately ignored
Sometimes the right answer for a bad value is neither correction nor failure. The user looks at a row where the phone number field contains "TBD" and decides: skip this field for this row, import everything else. This is different from a blank value (which might be valid or might trigger a required validation error) and different from a correction (which replaces the original). It is an explicit decision to exclude one field from one row's import.
The ImportRow model tracks this with a skipped JSON column. Keys are column identifiers, values are boolean true:
public function isValueSkipped(string $column): bool
{
return $this->skipped?->has($column) ?? false;
}
When a value is skipped, three things happen. First, the getFinalValue method returns null for that column, so the execution pipeline treats it as absent. Second, any existing correction for that column is removed -- skip and correct are mutually exclusive states. Third, the validation error is preserved in storage but the row is excluded from the "needs review" filter. The user can see that the value was skipped, but it no longer blocks the import from proceeding.
The review UI exposes this through filter tabs. "Needs Review" shows rows with unresolved validation errors. "Modified" shows rows with corrections. "Skipped" shows rows where the user deliberately ignored a field. These filters are computed per column, so the user can quickly assess the state of each mapped field:
| Filter | Meaning |
|---|---|
| All | Every unique value for this column |
| Needs Review | Has a validation error, not skipped |
| Modified | Has a correction, not skipped |
| Skipped | User chose to ignore this field |
The unskip action reverses the decision. The original validation error resurfaces, and the value returns to the "needs review" state. No data is lost at any point in this cycle.
#Failed rows after execution: the runtime safety net
Validation catches most problems before execution begins. But some errors only surface at runtime -- a unique constraint violation that the validation layer could not predict, a database timeout on a particularly large row, or an exception thrown by a lifecycle hook in the importer's beforeSave or afterSave method.
These runtime failures produce FailedImportRow records. A unique constraint violation, for example, can slip past the validation stage entirely -- Intra-import deduplication covers the proactive approach to preventing these before execution starts. When the execution job catches an exception while processing a row, it records the row number, the raw data, and the error message (truncated to 500 characters to prevent storage bloat from stack traces):
// Inside ExecuteImportJob -- recording runtime failures
try {
// ... create or update the model ...
$importer->afterSave($record, $context);
} catch (\Throwable $e) {
$results['failed']++;
$this->recordFailedRow($row->row_number, $row->raw_data->all(), $e);
report($e);
}
Failed rows are flushed to the FailedImportRow table in batches of 100 at the end of each chunk, and again if the job itself fails. This ensures that even if the queue worker crashes mid-import, the failed rows recorded up to that point are persisted. This is one of the runtime failure modes that The hidden cost of building your own CSV importer identifies as an iceberg layer -- most DIY importers write failed row records inconsistently or not at all.
The FailedImportRow model is separate from ImportRow by design. Import rows are the working state of the import -- they exist during the wizard flow and are cleaned up afterward. Failed import rows are a diagnostic record -- they persist after the import completes so that developers and users can investigate what went wrong.
#Auto-pruning
Failed rows do not accumulate forever. The FailedImportRow model uses Laravel's MassPrunable trait with a configurable retention period:
class FailedImportRow extends Model
{
use MassPrunable;
public function prunable(): Builder
{
$days = config('tapix.cleanup_after_days', 30);
return self::query()->where('created_at', '<=', now()->subDays($days));
}
}
The default retention is 30 days. After that, php artisan model:prune removes them. This keeps the table from growing unbounded on applications that run frequent imports, while preserving enough history for post-import debugging.
If you need longer retention for compliance or auditing, set cleanup_after_days in your tapix.php config. If you need permanent records, pipe the ImportFailed event to your own storage -- a log aggregator, an audit trail table, or a notification channel.
#The full error lifecycle
Putting it all together, a single value in a CSV import can pass through these states:
- Uploaded: Raw value stored in
raw_dataJSON. No errors yet. - Validated: Column validator checks the value. Error stored in
validationJSON, or no error and the value passes through. - Corrected: User types a new value in the review UI. Correction stored in
correctionsJSON. Re-validated immediately. - Skipped: User decides to ignore this field for this row.
skippedJSON records the decision. Correction removed if present. - Executed:
getFinalValuemerges corrections over raw data. Skipped and errored fields return null. - Failed at runtime: Database or application exception during insert/update. Row recorded in
FailedImportRowwith error message.
At no point in this lifecycle is data silently lost. The original value is always in raw_data. Corrections are tracked separately. Skips are explicit. Runtime failures are recorded. The user has visibility into every state transition.
This is the difference between an import system that works on the developer's machine with clean test data and one that survives contact with 10,000 rows of real-world CSV data authored by someone who has never heard of ISO 8601.
#Further reading
For the full import pipeline from upload to execution, see The complete guide to CSV imports in Laravel. For the validation layer that feeds into the correction flow described here, see Handling CSV validation errors before they hit your database. For the column mapping step that determines which fields get validated in the first place, see CSV column mapping UX patterns that reduce support tickets.
Tapix handles the entire error recovery lifecycle out of the box -- per-column validation, inline correction with re-validation, skip tracking, and failed row recording with auto-pruning. The review step shows every error grouped by unique value, lets users fix or skip in place, and re-validates before execution begins. No CSV downloads. No re-uploads. No lost data. See how it works.