Testing CSV importers in Laravel with Pest
Testing CSV importers in Laravel with Pest
Testing CSV imports is one of those tasks most Laravel developers skip until it is too late. The feature works in development with a clean test file. It fails in production with a 4,000-row export from a legacy CRM that has blank columns, mismatched header names, and a date format the validation layer has never seen.
The core problem is that testing CSV importers touches multiple system layers at once: file I/O, database state, queue jobs, and side effects like relationship resolution. That combination makes developers reach for manual testing instead of automated tests. This post covers a systematic approach to testing CSV imports in Laravel using Pest v4 -- from unit-level column mapping tests through full pipeline integration tests and Filament wizard component tests.
All examples use the Tapix importer architecture (BaseImporter, ImportField, ImportRow) but the patterns apply to any CSV import system built on Laravel.
#Why CSV imports are hard to test
A CSV import is not a single operation -- it is a pipeline. Each stage can fail independently, and the failure modes are different at each stage.
File I/O introduces path-handling edge cases, encoding problems, and memory concerns that do not appear in unit tests against hardcoded arrays. Test files need to exist on disk or be synthesized correctly.
Database state matters more for imports than for most features. The execute phase creates or updates records based on what already exists. A test that does not set up the right pre-existing state will pass for the create path and silently miss the update path entirely.
Queue jobs run asynchronously. Testing them requires either faking the queue (to assert dispatch) or running them synchronously (to assert outcomes). Both approaches have blind spots if you only use one.
Side effects -- relationship resolution, events fired, cache invalidation, external API calls -- are the parts that break silently. A contact import that creates companies via MatchBehavior::MatchOrCreate has five ways to go wrong during relationship resolution, and none of them show up in a test that only asserts assertDatabaseHas(Contact::class, ...).
Most developers skip import tests entirely, then discover bugs when a real user uploads a file with edge cases that the developer's own test fixture never included. The solution is not writing one test -- it is testing each layer of the pipeline independently, then combining them in integration tests.
What needs coverage:
- Column mapping: auto-detection, aliases, unknown headers, case sensitivity
- Validation: required fields, field types, choice fields, date formats
- Execution: create path, update path, relationship linking, skip logic
- Edge cases: empty files, all-bad rows, duplicates, special characters
- Queue: job dispatch, uniqueness constraint, failure events
- Filament: wizard navigation, component rendering
#Setting up test fixtures
Good import tests need realistic CSV data -- not fake files with no content, and not hardcoded strings that do not exercise the actual CSV parser.
#Creating test CSV files
Store fixture files in tests/fixtures/ and commit them to version control. Give each fixture a descriptive name that explains what it tests:
mkdir -p tests/fixtures
tests/fixtures/
contacts-clean.csv # 10 rows, all valid
contacts-messy.csv # mixed types, blank rows, duplicates
contacts-empty.csv # headers only, no data rows
contacts-utf8.csv # special characters, non-ASCII names
contacts-single-row.csv # exactly one row
A clean fixture file for contact imports:
first_name,last_name,email,phone,hired_at
Alice,Martin,alice@example.com,+1-555-0101,2024-01-15
Bob,Chen,bob@example.com,+1-555-0102,2023-08-22
Carol,Smith,carol@example.com,+1-555-0103,2024-03-10
A messy fixture that exercises edge cases:
First Name,Last Name,EMAIL,phone number,Date Hired
Alice,Martin,alice@example.com,555-0101,01/15/2024
,Chen,not-an-email,,2023-08-22
Bob,,bob@example.com,+1-555-0103,
Carol,Smith,carol@example.com,555-0104,2024/03/10
Carol,Smith,carol@example.com,555-0104,2024/03/10
Row 2 has a blank first name. Row 3 has an invalid email and blank dates. Row 4 has a blank last name. Rows 5 and 6 are identical duplicates. This is what real data looks like.
#Synthesizing UploadedFile instances
Pest tests need an Illuminate\Http\UploadedFile to simulate file uploads. Use UploadedFile::fake() with real CSV content, not UploadedFile::fake()->create() which produces an empty file:
use Illuminate\Http\UploadedFile;
function csvUpload(string $fixtureName): UploadedFile
{
$path = base_path("tests/fixtures/{$fixtureName}");
return new UploadedFile(
path: $path,
originalName: $fixtureName,
mimeType: 'text/csv',
error: UPLOAD_ERR_OK,
test: true,
);
}
Place this helper in tests/Helpers.php and autoload it via composer.json:
"autoload-dev": {
"files": ["tests/Helpers.php"]
}
For in-memory CSV content without a fixture file:
function csvFromString(string $content, string $name = 'import.csv'): UploadedFile
{
$tmp = tempnam(sys_get_temp_dir(), 'csv');
file_put_contents($tmp, $content);
return new UploadedFile(
path: $tmp,
originalName: $name,
mimeType: 'text/csv',
error: UPLOAD_ERR_OK,
test: true,
);
}
#Import and ImportRow factories
Never create Import and ImportRow records manually in tests. Use factories so your tests stay independent from the schema and pick up factory state definitions automatically.
A minimal ImportFactory:
namespace Database\Factories;
use Tapix\Core\Models\Import;
use Tapix\Core\Enums\ImportStatus;
use Illuminate\Database\Eloquent\Factories\Factory;
class ImportFactory extends Factory
{
protected $model = Import::class;
public function definition(): array
{
return [
'id' => fake()->ulid(),
'importer_class' => \App\Importers\ContactImporter::class,
'file_name' => 'contacts.csv',
'status' => ImportStatus::Mapping,
'column_map' => null,
'total_rows' => 0,
];
}
public function completed(): static
{
return $this->state(['status' => ImportStatus::Completed]);
}
public function withColumnMap(array $map): static
{
return $this->state(['column_map' => $map]);
}
}
#Testing column mapping
Column mapping is the first thing users interact with and the first thing that breaks when their CSV has "First Name" instead of "first_name". Test it at the unit level against the importer's field definitions.
#Auto-mapping correct headers
use App\Importers\ContactImporter;
use Tapix\Core\Mapping\ColumnMapper;
test('auto-mapper maps exact header matches', function () {
$importer = new ContactImporter;
$headers = ['first_name', 'last_name', 'email'];
$result = ColumnMapper::autoMap($headers, $importer->fields());
expect($result)
->toHaveKey('first_name', 'first_name')
->toHaveKey('last_name', 'last_name')
->toHaveKey('email', 'email');
});
#Alias matching
The guess() method on ImportField registers header aliases. Test that common human-readable variants map to the right field:
test('auto-mapper resolves common header aliases', function () {
$importer = new ContactImporter;
$headers = ['First Name', 'Last Name', 'Email Address'];
$result = ColumnMapper::autoMap($headers, $importer->fields());
expect($result)
->toHaveKey('First Name', 'first_name')
->toHaveKey('Last Name', 'last_name')
->toHaveKey('Email Address', 'email');
});
test('auto-mapper handles informal aliases', function () {
$importer = new ContactImporter;
$headers = ['fname', 'lname', 'e-mail'];
$result = ColumnMapper::autoMap($headers, $importer->fields());
expect($result['fname'])->toBe('first_name');
expect($result['lname'])->toBe('last_name');
});
#Unknown headers left unmapped
When a CSV header has no matching field, the mapper should not guess or assign it to a random field. Test that unknown headers return a null mapping:
test('auto-mapper leaves unknown headers unmapped', function () {
$importer = new ContactImporter;
$headers = ['first_name', 'email', 'favorite_color', 'shoe_size'];
$result = ColumnMapper::autoMap($headers, $importer->fields());
expect($result)
->toHaveKey('first_name', 'first_name')
->toHaveKey('email', 'email')
->toHaveKey('favorite_color', null)
->toHaveKey('shoe_size', null);
});
#Case-insensitive matching
test('auto-mapper matches headers case-insensitively', function (string $header) {
$importer = new ContactImporter;
$result = ColumnMapper::autoMap([$header], $importer->fields());
expect($result[$header])->toBe('email');
})->with(['EMAIL', 'Email', 'eMaIl', 'email']);
The with() dataset runs this test four times with different case variants. Any one of them failing reveals a case-sensitivity bug in the mapper.
#Testing validation rules
Validation tests work best at the field level: test one field's rules with valid and invalid inputs, assert the correct error structure. Use datasets to cover multiple bad values without duplicating test bodies.
#Required field with blank value
use Tapix\Core\Jobs\ValidateColumnJob;
test('required field rejects blank values', function () {
$import = Import::factory()->withColumnMap(['first_name' => 'first_name'])->create();
ValidateColumnJob::dispatchSync($import->id, 'first_name');
$rows = ImportRow::forImport($import->id)->withErrors()->get();
expect($rows)->toHaveCount(1)
->and($rows->first()->validation)
->toHaveKey('first_name');
});
#Email field validation
test('email field rejects invalid formats', function (string $badEmail) {
$import = Import::factory()->create();
ImportRow::factory()->for($import)->create([
'raw_data' => ['email' => $badEmail, 'first_name' => 'Alice'],
]);
ValidateColumnJob::dispatchSync($import->id, 'email');
$errorRows = ImportRow::forImport($import->id)->withErrors()->get();
expect($errorRows)->toHaveCount(1);
})->with([
'plaintext',
'missing@',
'@nodomain',
'double@@at.com',
'',
]);
test('email field accepts valid addresses', function (string $goodEmail) {
$import = Import::factory()->create();
ImportRow::factory()->for($import)->create([
'raw_data' => ['email' => $goodEmail, 'first_name' => 'Alice'],
]);
ValidateColumnJob::dispatchSync($import->id, 'email');
$errorRows = ImportRow::forImport($import->id)->withErrors()->get();
expect($errorRows)->toBeEmpty();
})->with([
'alice@example.com',
'alice+tag@example.co.uk',
'alice.martin@sub.domain.org',
]);
#Date field with ambiguous format
Date parsing is one of the most common failure modes in real imports. Test both formats that should parse and formats that should not:
test('date field rejects unrecognizable formats', function (string $value) {
$import = Import::factory()->create();
ImportRow::factory()->for($import)->create([
'raw_data' => ['hired_at' => $value, 'first_name' => 'Alice', 'email' => 'a@b.com'],
]);
ValidateColumnJob::dispatchSync($import->id, 'hired_at');
expect(ImportRow::forImport($import->id)->withErrors()->count())->toBe(1);
})->with([
'not-a-date',
'32/01/2024',
'2024-13-01',
]);
test('date field accepts common date formats', function (string $value) {
$import = Import::factory()->create();
ImportRow::factory()->for($import)->create([
'raw_data' => ['hired_at' => $value, 'first_name' => 'Alice', 'email' => 'a@b.com'],
]);
ValidateColumnJob::dispatchSync($import->id, 'hired_at');
expect(ImportRow::forImport($import->id)->withErrors()->count())->toBe(0);
})->with([
'2024-01-15',
'01/15/2024',
'15/01/2024',
'January 15, 2024',
]);
#Choice field with value not in options
test('choice field rejects values outside defined options', function () {
$import = Import::factory()->create();
ImportRow::factory()->for($import)->create([
'raw_data' => ['status' => 'superstar', 'first_name' => 'Alice', 'email' => 'a@b.com'],
]);
ValidateColumnJob::dispatchSync($import->id, 'status');
$row = ImportRow::forImport($import->id)->first();
expect($row->validation)->toHaveKey('status')
->and($row->validation['status'])->toContain('superstar');
});
test('choice field accepts defined options case-insensitively', function (string $value) {
$import = Import::factory()->create();
ImportRow::factory()->for($import)->create([
'raw_data' => ['status' => $value, 'first_name' => 'Alice', 'email' => 'a@b.com'],
]);
ValidateColumnJob::dispatchSync($import->id, 'status');
expect(ImportRow::forImport($import->id)->withErrors()->count())->toBe(0);
})->with(['active', 'ACTIVE', 'Active', 'inactive']);
#Testing the full import pipeline
Integration tests run the entire pipeline -- upload, mapping, validation, execution -- and assert database state and import metrics. These are slower than unit tests, but they catch the bugs that field-level tests miss.
#Happy path: end-to-end create
use Illuminate\Support\Facades\Queue;
test('full pipeline creates records from clean CSV', function () {
Queue::fake();
$csv = csvFromString(
"first_name,last_name,email\nAlice,Martin,alice@example.com\nBob,Chen,bob@example.com"
);
$import = $this->importerService->upload($csv, ContactImporter::class);
$this->importerService->applyMapping($import, ['first_name' => 'first_name', 'last_name' => 'last_name', 'email' => 'email']);
$this->importerService->validate($import);
$this->importerService->execute($import);
expect(Contact::count())->toBe(2)
->and(Contact::where('email', 'alice@example.com')->exists())->toBeTrue()
->and(Contact::where('email', 'bob@example.com')->exists())->toBeTrue();
$import->refresh();
expect($import->status)->toBe(ImportStatus::Completed);
});
#Update path: existing records matched by key
test('execute updates existing records when match key exists', function () {
$existing = Contact::factory()->create([
'email' => 'alice@example.com',
'first_name' => 'Alice',
'last_name' => 'Old',
]);
$import = Import::factory()
->withColumnMap(['email' => 'email', 'first_name' => 'first_name', 'last_name' => 'last_name'])
->create();
ImportRow::factory()->for($import)->create([
'raw_data' => ['email' => 'alice@example.com', 'first_name' => 'Alice', 'last_name' => 'Martin'],
]);
ExecuteImportJob::dispatchSync($import->id);
$existing->refresh();
expect($existing->last_name)->toBe('Martin')
->and(Contact::count())->toBe(1);
});
#Relationships created correctly
test('execute creates BelongsTo relationship via MatchOrCreate', function () {
expect(Company::count())->toBe(0);
$import = Import::factory()
->withColumnMap(['first_name' => 'first_name', 'email' => 'email', 'company' => 'company'])
->create();
ImportRow::factory()->for($import)->create([
'raw_data' => ['first_name' => 'Alice', 'email' => 'alice@example.com', 'company' => 'Acme Corp'],
]);
ExecuteImportJob::dispatchSync($import->id);
expect(Company::where('name', 'Acme Corp')->exists())->toBeTrue()
->and(Contact::first()->company->name)->toBe('Acme Corp');
});
test('execute reuses existing company on second import row', function () {
$import = Import::factory()
->withColumnMap(['first_name' => 'first_name', 'email' => 'email', 'company' => 'company'])
->create();
ImportRow::factory()->for($import)->createMany([
['raw_data' => ['first_name' => 'Alice', 'email' => 'alice@example.com', 'company' => 'Acme Corp']],
['raw_data' => ['first_name' => 'Bob', 'email' => 'bob@example.com', 'company' => 'Acme Corp']],
]);
ExecuteImportJob::dispatchSync($import->id);
expect(Company::where('name', 'Acme Corp')->count())->toBe(1)
->and(Contact::count())->toBe(2);
});
#Import row counts
After execution, the import model tracks how many rows were created, updated, skipped, and failed. Assert these counts explicitly:
test('import counts reflect actual processing outcomes', function () {
$existing = Contact::factory()->create(['email' => 'existing@example.com']);
$import = Import::factory()
->withColumnMap(['first_name' => 'first_name', 'email' => 'email'])
->create();
ImportRow::factory()->for($import)->createMany([
['raw_data' => ['first_name' => 'Alice', 'email' => 'new@example.com']],
['raw_data' => ['first_name' => 'Bob', 'email' => 'existing@example.com']],
['raw_data' => ['first_name' => '', 'email' => 'bad@example.com'], 'skipped' => true],
]);
ExecuteImportJob::dispatchSync($import->id);
$import->refresh();
expect($import->created_rows)->toBe(1)
->and($import->updated_rows)->toBe(1)
->and($import->skipped_rows)->toBe(1)
->and($import->failed_rows)->toBe(0);
});
#Testing edge cases
Edge cases are what break real imports. Every test suite for a CSV importer should cover these explicitly.
#Empty file (headers only, no data rows)
test('empty CSV with only headers completes without creating records', function () {
$csv = csvFromString("first_name,last_name,email\n");
$import = $this->importerService->upload($csv, ContactImporter::class);
$this->importerService->applyMapping($import, [
'first_name' => 'first_name',
'last_name' => 'last_name',
'email' => 'email',
]);
$this->importerService->execute($import);
expect(Contact::count())->toBe(0);
$import->refresh();
expect($import->status)->toBe(ImportStatus::Completed)
->and($import->total_rows)->toBe(0);
});
#All rows have validation errors
test('import with zero valid rows completes with failed status', function () {
$import = Import::factory()
->withColumnMap(['first_name' => 'first_name', 'email' => 'email'])
->create();
ImportRow::factory()->for($import)->createMany([
['raw_data' => ['first_name' => 'Alice', 'email' => 'not-an-email']],
['raw_data' => ['first_name' => 'Bob', 'email' => 'also-not']],
]);
ValidateColumnJob::dispatchSync($import->id, 'email');
ExecuteImportJob::dispatchSync($import->id);
expect(Contact::count())->toBe(0);
$import->refresh();
expect($import->failed_rows)->toBe(2)
->and($import->created_rows)->toBe(0);
});
#Duplicate rows in the same file
Intra-file deduplication should prevent the same logical record from being created twice. For more on this pattern, see Intra-import deduplication -- but always cover it in tests:
test('duplicate rows in the same file create only one record', function () {
$import = Import::factory()
->withColumnMap(['first_name' => 'first_name', 'email' => 'email'])
->create();
$row = ['first_name' => 'Alice', 'email' => 'alice@example.com'];
ImportRow::factory()->for($import)->createMany([
['raw_data' => $row],
['raw_data' => $row],
['raw_data' => $row],
]);
ExecuteImportJob::dispatchSync($import->id);
expect(Contact::where('email', 'alice@example.com')->count())->toBe(1);
});
#Special characters and UTF-8
test('import handles UTF-8 special characters in field values', function () {
$import = Import::factory()
->withColumnMap(['first_name' => 'first_name', 'email' => 'email'])
->create();
ImportRow::factory()->for($import)->create([
'raw_data' => ['first_name' => 'Ångström Müller-Lüdenscheidt', 'email' => 'angstrom@example.com'],
]);
ExecuteImportJob::dispatchSync($import->id);
expect(Contact::where('email', 'angstrom@example.com')->value('first_name'))
->toBe('Ångström Müller-Lüdenscheidt');
});
test('import handles values with embedded commas and quotes', function () {
$csv = csvFromString(
"first_name,last_name,email\n\"Smith, Jr.\",\"O'Brien\",smithjr@example.com"
);
$import = $this->importerService->upload($csv, ContactImporter::class);
$this->importerService->applyMapping($import, [
'first_name' => 'first_name',
'last_name' => 'last_name',
'email' => 'email',
]);
$this->importerService->execute($import);
expect(Contact::where('email', 'smithjr@example.com')->value('first_name'))
->toBe('Smith, Jr.');
});
#Testing queue jobs
Queue tests split into two modes: fake queue for asserting dispatch behavior, synchronous queue for asserting outcomes.
#Assert jobs are dispatched
use Illuminate\Support\Facades\Queue;
use Tapix\Core\Jobs\ExecuteImportJob;
use Tapix\Core\Jobs\ValidateColumnJob;
test('starting import dispatches validation jobs for each mapped column', function () {
Queue::fake();
$import = Import::factory()
->withColumnMap(['first_name' => 'first_name', 'email' => 'email'])
->create();
$this->importerService->startValidation($import);
Queue::assertPushed(ValidateColumnJob::class, 2);
Queue::assertPushed(ValidateColumnJob::class, function (ValidateColumnJob $job) use ($import) {
return $job->importId === $import->id && $job->column === 'first_name';
});
});
test('executing import dispatches ExecuteImportJob', function () {
Queue::fake();
$import = Import::factory()->create();
$this->importerService->execute($import);
Queue::assertPushed(ExecuteImportJob::class, function (ExecuteImportJob $job) use ($import) {
return $job->importId === $import->id;
});
});
#ShouldBeUnique prevents duplicate dispatch
ExecuteImportJob implements ShouldBeUnique to prevent the same import from being executed twice concurrently. Test this with the sync driver, which surfaces uniqueness failures immediately. See the Laravel queue testing docs for queue fake options:
test('dispatching ExecuteImportJob twice only runs it once', function () {
config(['queue.default' => 'sync']);
$import = Import::factory()->create();
$count = 0;
// Wrap dispatch in a Job mock that counts invocations
// In practice, assert the unique key is set correctly
$job = new ExecuteImportJob($import->id);
expect($job->uniqueId())->toBe($import->id);
});
#Job failure fires ImportFailed event
use Tapix\Core\Events\ImportFailed;
use Illuminate\Support\Facades\Event;
test('ExecuteImportJob failure dispatches ImportFailed event', function () {
Event::fake([ImportFailed::class]);
$import = Import::factory()->create();
// Simulate a job failure by dispatching with invalid state
// Force the job to fail by corrupting importer class
$import->update(['importer_class' => 'NonExistentImporter']);
try {
ExecuteImportJob::dispatchSync($import->id);
} catch (\Throwable) {
// Expected -- we forced a failure
}
Event::assertDispatched(ImportFailed::class, function (ImportFailed $event) use ($import) {
return $event->import->id === $import->id;
});
});
#End-to-end with sync driver
The sync driver is the most reliable way to test the full async pipeline in tests because it runs jobs inline, without mocking:
test('full pipeline runs end-to-end with sync queue driver', function () {
config(['queue.default' => 'sync']);
$csv = csvFromString(
"first_name,email\nAlice,alice@example.com\nBob,bob@example.com"
);
$import = $this->importerService->upload($csv, ContactImporter::class);
$this->importerService->applyMapping($import, [
'first_name' => 'first_name',
'email' => 'email',
]);
$this->importerService->runPipeline($import);
expect(Contact::count())->toBe(2);
$import->refresh();
expect($import->status)->toBe(ImportStatus::Completed);
});
#Testing Filament integration
The Filament plugin adds an import wizard page and an import history resource to your panel. Test these with Livewire's test helpers. See Adding a CSV import wizard to your Filament panel for the plugin registration steps and The complete guide to CSV imports in Laravel for background.
#Plugin page renders
use function Pest\Livewire\livewire;
use Tapix\Core\Livewire\ImportWizard;
beforeEach(function () {
$this->actingAs(User::factory()->create());
});
test('import wizard page renders', function () {
livewire(ImportWizard::class, [
'importerClass' => ContactImporter::class,
])
->assertSuccessful()
->assertSee('Upload');
});
test('import history page renders', function () {
livewire(\Tapix\Core\Filament\Resources\ImportResource\Pages\ListImports::class)
->assertSuccessful()
->assertSeeText('Imports');
});
#Wizard step navigation
Test that advancing through the wizard steps works correctly and that the component transitions state:
test('upload step transitions to mapping step on valid file', function () {
$component = livewire(ImportWizard::class, [
'importerClass' => ContactImporter::class,
]);
$csv = csvFromString("first_name,email\nAlice,alice@example.com");
$component
->set('file', $csv)
->call('upload')
->assertSet('currentStep', 'mapping');
});
test('mapping step shows detected column headers', function () {
$import = Import::factory()
->withColumnMap(['first_name' => null, 'email' => null])
->create();
livewire(ImportWizard::class, [
'importerClass' => ContactImporter::class,
'storeId' => $import->id,
])
->assertSeeText('first_name')
->assertSeeText('email');
});
test('review step shows validation error count', function () {
$import = Import::factory()->create();
ImportRow::factory()->for($import)->create([
'raw_data' => ['first_name' => '', 'email' => 'bad'],
'validation' => ['first_name' => ['This field is required.'], 'email' => ['Invalid email address.']],
]);
livewire(ImportWizard::class, [
'importerClass' => ContactImporter::class,
'storeId' => $import->id,
])
->call('goToStep', 'review')
->assertSee('2 errors');
});
For Pest Livewire integration, see the Pest PHP expectations docs for fluent assertion chaining available in Livewire component tests.
#A testing checklist
Every importer that ships to production should have these tests passing:
Mapping layer:
- Auto-mapper correctly identifies exact header matches
- Alias matching works for at least three common variants per field
- Unknown headers are left unmapped, not silently dropped or wrong-assigned
- Case-insensitive matching
Validation layer:
- Each required field rejects blank values
- Each typed field (email, date, number, boolean, choice) rejects at least three invalid inputs
- Valid inputs for each field type pass without errors
- Dataset-driven tests so new bad inputs can be added as a one-liner
Execution layer:
- Create path: new records are inserted with correct field values
- Update path: existing records matched by key are updated, not duplicated
- Relationship resolution:
MatchOrCreatecreates missing related records,MatchOnlyskips unmatched rows - Import row counts match actual create/update/skip/fail outcomes
- Import status transitions to
Completedon success
Edge cases:
- Empty file (headers only) completes with zero records and zero errors
- All-invalid rows: import completes, zero records created
- Duplicate rows: only one record created per unique key
- UTF-8 characters in string fields are preserved
- CSV values with commas and quotes parse correctly
Queue:
- Validation jobs are dispatched for each mapped column
ExecuteImportJobhas auniqueId()based on import ID- Job failure fires
ImportFailedevent - Full pipeline passes with sync driver end-to-end
Filament:
- Wizard page renders without errors for authenticated users
- Upload step advances to mapping step on valid file
- Review step shows correct error counts
For deeper coverage of what happens when validation fails and users correct errors inline, see Handling CSV validation errors before they hit your database and CSV import error recovery: from silent failures to user-friendly correction.
For performance testing patterns with large files and queue worker behavior under load, see Queue-powered imports: processing 100K rows in Laravel.