Inside a queued Laravel job that imports a CSV file, how would you validate each row and report bad rows without failing the whole import?
answer
- one validator per row
- fails() and store errors()->toArray()
- don't throw ValidationException from the job
- exists/unique per row means a query per row
- empty cells stay '' — no middleware here
basics
~10 sBuild Validator::make($row, $rules) per row, check fails(), and record the row number with errors()->toArray() instead of throwing. Persist validated() for good rows. Throwing ValidationException would fail the job attempt rather than skip the row.
solid answer
~40 sA job has no request, so `$request->validate()` is out; build `Validator::make($row, $rules)` for each associative row and call `fails()`. For bad rows, store the row number and `errors()->toArray()` in an import-errors table; for good rows, persist `validated()`. Do not let `ValidationException` escape: in a worker it is just an exception, so the attempt fails and may retry from row one, and the exception handler does not report it, so it will not show in your logs. Watch the costs: `exists` or `unique` on a column runs a query per row, so for large files pre-load the keys and check in an `after()` callback. Also remember HTTP middleware does not run in a job, so empty CSV cells arrive as empty strings, not `null`.
code
php · 30 lines<?php
use App\Models\Category;
use App\Models\Product;
use Illuminate\Support\Facades\Validator;
// Inside App\Jobs\ImportProducts, a ShouldQueue job holding $this->import.
public function handle(): void
{
$slugs = Category::pluck('slug')->flip();
foreach ($this->rows() as $line => $row) {
$validator = Validator::make($row, [
'sku' => ['required', 'string', 'max:40'],
'price' => ['required', 'numeric', 'min:0'],
'category' => ['required', 'string'],
])->after(function ($v) use ($row, $slugs) {
if (! $v->errors()->has('category') && ! isset($slugs[$row['category']])) {
$v->errors()->add('category', 'Unknown category.');
}
});
if ($validator->fails()) {
$this->import->rowErrors()->create(['line' => $line, 'messages' => $validator->errors()->toArray()]);
continue;
}
Product::updateOrCreate(['sku' => $row['sku']], $validator->validated());
}
}go deeper
Know that jobs have no request, so rows are validated with Validator::make() and fails().
Explain storing errors per row, persisting validated(), and why empty cells stay empty strings.
Avoid exceptions escaping the job, pre-load lookups instead of per-row queries, and keep retries from duplicating rows.
Choose between per-row tolerance and all-or-nothing imports with the business, and design how rejected rows are surfaced.
## The shape of the problem An admin uploads `products.csv`; a controller stores the file and dispatches `ImportProducts`. The job reads rows one by one. Some rows are fine, some have a missing SKU, a non-numeric price or an unknown category. The business wants good rows imported and bad rows listed with their line numbers — not an all-or-nothing failure. ## Why the usual tools do not fit - `$request->validate()` validates `$request->all()`; inside a worker there is no current request. - A form request is resolved per HTTP request; it cannot validate rows. - Throwing `ValidationException` is meaningful only to the HTTP exception handler. In a worker it is an ordinary exception: the job attempt fails, and depending on retries it may run again from the start. It is also on the handler's internal don't-report list, so it does not appear in your logs. So the job uses **manual validators** and treats failure as data. ## The per-row loop 1. Read the header once and combine it with each row into an associative array (`array_combine($header, $row)`). 2. Build `Validator::make($row, $rules, [], $attributeNames)`. 3. If `fails()`, save the row number and `errors()->toArray()` to an `import_errors` table and continue. 4. Otherwise, persist `validated()` — only the columns that had rules, so an unexpected CSV column is ignored. 5. At the end, mark the import as completed with counts of accepted and rejected rows. ## Rules that behave differently in a job - **Empty cells.** The web middleware that trims strings and converts empty strings to `null` does not run for jobs. An empty CSV cell is `""`, and ordinary rules skip empty strings, so a rule list without `required` silently accepts blanks. Be explicit about which columns must be filled. - **Everything is a string.** CSV values are text. Rules like `integer`, `numeric` and `boolean` accept numeric strings, but you still need to cast before saving. - **Database rules cost a query each.** `exists:categories,slug` on 10,000 rows is 10,000 queries. ## Making database checks affordable Load the valid keys once and check in memory: - Before the loop: `$slugs = Category::pluck('slug')->flip();` - Per row: an `after()` callback on the validator adds an error when `! isset($slugs[$row['category']])`. - `stopOnFirstFailure()` on each validator skips remaining fields once one fails, when you only need to know that a row is bad. ## Idempotency and retries Because the job can be retried after a crash, write rows so a rerun does not duplicate them — for example by upserting on SKU — and record progress (last processed line) on the import record. That concern belongs to job design generally; the validation-specific point is that per-row validation keeps a single bad row from triggering a retry at all. ## Comparison of failure strategies | Strategy | Effect on the import | |---|---| | Throw `ValidationException` on first bad row | job attempt fails; later rows never processed; not logged | | `validate()` inside `try`/`catch` per row | works, but uses exceptions for control flow | | `fails()` + store `errors()` per row | good rows saved, bad rows reported with messages | | Validate the whole file as one array with `rows.*.sku` | one huge validator; errors keyed `rows.123.sku`; all-or-nothing by default | The third row is the usual answer. The fourth can work for small files where all-or-nothing is the requirement. ## Reporting back Store errors with the row number the user sees in a spreadsheet (header is line 1, so data row index + 2). Let the UI show "Row 17: The price field must be a number." Custom attribute names passed as the fourth argument of `Validator::make()` make those messages readable. ## What a strong answer includes 1. `Validator::make()` per row and `fails()`, never `$request->validate()`. 2. Errors stored as data with row numbers; good rows saved from `validated()`. 3. No `ValidationException` escaping the job, and why (retries, no response, not logged). 4. The job-specific rule behaviour: empty strings, strings everywhere, a query per database rule. 5. A retry-safe write such as an upsert keyed on the SKU.
- Why not validate the whole CSV as one array with rows.*.sku rules?It can work for small files, but it builds one validator over every row, holds all data in memory, and treats the file as all-or-nothing unless you pick valid rows out of the errors. Per-row validators keep memory flat and map naturally to 'import good rows, report bad ones'.
- Why can an import job accept a blank required-looking column without error?In a job, no middleware converts empty strings to null. An empty cell stays "", and ordinary rules such as `string` or `max` are skipped for empty strings. Only implicit rules like `required` run on it, so columns that must be filled need `required`.
saying these in an interview costs you the question
- Use $request->validate() inside the job to check each row
- Throwing ValidationException in a job sends a 422 to the uploader
- Empty CSV cells arrive as null, just like in a form post
- An exists rule per row costs nothing extra on large files