Preview a CSV import before you commit it
Almost every app that manages people eventually gets the same request: “Can I just upload a spreadsheet?” Nobody wants to type 80 names into an invite form. In a side project I’m building, a team owner needed to bring their whole roster in at once, so I added a CSV upload that creates and emails an invitation for each row.
The naive version is a loop. Read the file, create an invitation per line, send the email, show “Done!” It works on the clean test file you made yourself. It falls apart on the file a real person exports from some other system, because that file has a blank row in the middle, the same email twice, a role spelled “Agent “ with a trailing space, and two people who are already on the team.
The worst part of the naive loop isn’t that it fails. It’s that it half succeeds. Row 37 throws, rows 1 through 36 already sent emails, and now the person has no idea what happened or whether it’s safe to upload the file again.
Split it into preview and confirm
The pattern I keep coming back to is two steps:
- Preview. Parse the whole file, validate every row, and show a table: each row with either “ready” or the specific reason it won’t be imported. Nothing is written and no email goes out.
- Confirm. The person looks at the table, decides that’s what they meant, and clicks import. Only then do the invitations get created.
That one change fixes most of the pain. People fix their spreadsheet once instead of discovering problems one email at a time, and “skipped 3 rows, here’s why” is a far better outcome than a stack trace on row 37.
Validate the file before the rows
Some problems mean the whole file is wrong, and those should stop the preview with one clear message instead of producing 200 identical row errors:
- No usable header. I require a header row and look columns up by name, not position,
so
email,name,roleworks as well asname,email,role. If a required column is missing, say which columns the file needs. - Too many rows. Pick a cap (I used 500) and reject anything bigger up front. It keeps request times and email bursts sane, and someone with 5,000 rows is better served by a different tool anyway.
- A byte order mark. Spreadsheet apps love to save “CSV UTF-8” with an invisible BOM at
the start of the file. If you don’t strip it, your first header is
\uFEFFname, and the “name” column is mysteriously missing. This one costs everybody an afternoon once.
Then check each row, in order
Each row gets normalized first (trim whitespace, lowercase the email and role, default an empty role to the least-privileged one) and then gets exactly one error or none. Reporting the first problem per row keeps the table readable:
$seen = [];
foreach ($lines as $i => $line) {
if (trim($line) === '') {
continue; // Blank rows are noise, not errors.
}
$row = $this->normalize(str_getcsv($line), $header, lineNumber: $i + 2);
$row['error'] = match (true) {
$row['name'] === '' => 'Name is required.',
$row['email'] === '' => 'Email is required.',
isset($seen[$row['email']]) => 'Duplicate of line ' . $seen[$row['email']] . '.',
default => $this->invitations->problem($actor, $row['email'], $row['role']),
};
$seen[$row['email']] ??= $row['line'];
$rows[] = $row;
}
A few details that matter more than they look:
- Use the line number the person sees. The header is line 1, so the first data row is line 2. “Duplicate of line 14” is only useful if line 14 in their spreadsheet is the row you mean.
- Catch duplicates inside the file. The database can’t tell you that rows 6 and 14 are the same person, because neither exists yet. Track what you’ve already seen while you loop.
- Reuse the single-item rules. The
problem()call is the same check the regular one-person invite form uses: is the email valid, is this role allowed for the person doing the inviting, does an account already exist, is there already a pending invitation. If the import has its own copy of those rules, they will drift, and the CSV becomes the back door that lets someone invite an admin when the form wouldn’t.
Confirm means re-validate
Here’s the part that’s easy to skip. Between the preview and the click on “Import,” time passes. Maybe a minute, maybe the person went to lunch. In that window, a teammate might have invited one of those people by hand, or someone accepted an invitation and now has an account. The preview was true when it was rendered. It isn’t necessarily true now.
So the confirm step runs the same check again for every row that looked fine:
foreach ($rows as $row) {
if ($row['error'] ?? null) {
$results[] = $row + ['result' => 'skipped'];
continue;
}
if ($problem = $this->invitations->problem($actor, $row['email'], $row['role'])) {
$results[] = [...$row, 'error' => $problem, 'result' => 'skipped'];
continue;
}
$this->invitations->create($actor, $row + ['source' => 'csv_import']);
$results[] = $row + ['result' => 'invited'];
}
The result screen is the same table with a final status per row, so the person sees exactly
who was invited and who was skipped and why. I also write one audit entry for the whole
import with the invited and skipped counts, and each invitation carries
source: csv_import, so later on “where did this invite come from?” has an answer.
Don’t trust the preview you sent to the browser
The confirm step needs the parsed rows, and the tempting shortcut is to put them in hidden form fields and post them back. Don’t. Anything in the form can be edited, which means someone could submit rows that never went through validation.
I keep the parsed rows on the server instead, in the session under a random key, and the
confirm form only sends that key. On confirm I pull() the rows, which reads and deletes
them in one step:
$rows = $request->session()->pull('member_import.' . $key);
abort_unless(is_array($rows), 410, 'This import preview expired. Upload the file again.');
That makes each preview single use. A double click, a refresh, or the back button can’t send the same 80 invitations twice. If the key is gone, the person gets a clear “upload it again” message instead of a duplicate batch. Both endpoints are also rate limited, since a CSV import is a very efficient way to send a lot of email.
Tradeoffs
- Line-by-line parsing has limits. Splitting on newlines and parsing each line is fine for a simple roster, but it breaks on quoted fields that contain line breaks. If your data can have multi-line notes or addresses, use a real CSV reader that streams records, not lines.
- Session storage has a size ceiling. A few hundred short rows is nothing. Thousands of wide rows belong in a temporary table or a file keyed to the upload, with an expiry.
- Partial success is a choice. I import the good rows and skip the bad ones, because people would rather fix three rows than redo eighty. Some data (financial records, for example) is better as all or nothing inside one transaction. Decide on purpose and say which one you picked on the screen.
- Email in a loop is slow. For a few hundred invitations, queue the emails instead of sending them inside the request.
The checklist I’ll reuse
- File-level checks first: header by name, row cap, BOM stripped.
- Normalize every row, then report one clear error per row with the line number the person sees.
- Catch duplicates within the file, and reuse the exact rules the single-item form uses.
- Preview writes nothing. Confirm re-validates every row.
- Parsed rows stay on the server, and each preview can be confirmed only once.
- The result screen shows a status for every row, and the import is audited.