keboola/csv
Keboola CSV is a lightweight PHP library for reading and writing CSV files with full control over delimiters, enclosures, escape characters, line endings, and headers, plus validation and reliable handling of large files.
Install with Composer (no Laravel auto-discovery, no service provider shipped):
composer require keboola/csv
Requires PHP 7.4+ (works on 8.0–8.3 today). There are no Laravel bindings, no config, no facade — it is a small, procedural package. Read the source in this order: src/CsvOptions.php (value object for delimiter/enclosure/escape), src/CsvReader.php (iteration + random access), src/CsvWriter.php (append/truncate semantics), then src/CsvFile.php which combines both. src/AbstractCsv.php holds the shared fopen/line-reading/writing plumbing.
First use case — read a CSV, treat the first row as a header, and iterate as associative arrays:
use Keboola\Csv\CsvReader;
$reader = new CsvReader(storage_path('app/import.csv'));
$reader->setHeader($reader->getHeader()); // now rows are ['id' => '1', 'name' => 'Alice']
foreach ($reader as $row) {
User::updateOrCreate(['external_id' => $row['id']], ['name' => $row['name']]);
}
Writing is the mirror image:
use Keboola\Csv\CsvWriter;
$writer = new CsvWriter(storage_path('app/export.csv'));
$writer->writeRow(['id', 'name']);
foreach ($users as $user) {
$writer->writeRow([$user->id, $user->name]);
}
CsvWriter accepts any filename or stream wrapper (php://output, php://temp, storage_path(...)), so it drops straight into response()->streamDownload().
Centralize construction in a factory. Delimiters vary per integration (, ; \t |). Wrap the package so call sites never pass raw arguments:
// app/Support/Csv/CsvFactory.php
final class CsvFactory
{
public function reader(string $path, ?string $delimiter = null): CsvReader
{
return new CsvReader($path, new CsvOptions(
$delimiter ?? config('csv.delimiter', ','),
config('csv.enclosure', '"'),
config('csv.escape', '')
));
}
}
A single CsvOptions instance can be reused for a whole import — it is immutable-ish and cheap.
Reader with an explicit header contract. For imports, define the expected columns and fail fast before touching the DB:
$expected = ['sku', 'name', 'price'];
$actual = $reader->getHeader();
if (array_map('trim', $actual) !== $expected) {
throw new ImportException("Unexpected columns: " . implode(',', $actual));
}
$reader->setHeader($expected);
This is the single most valuable pattern; malformed CSVs from customers are the norm.
Streaming instead of slicing. getRows($start, $limit) and getTotalRows() are convenient for small files, but they scan the file. For job pipelines, iterate — the reader implements Iterator and only advances the file pointer as you go:
foreach ($reader as $row) { // memory-flat
$batch[] = $row;
if (count($batch) === 1000) { insert($batch); $batch = []; }
}
Export via StreamedResponse. Do not build a giant string; stream the writer to the response:
return response()->streamDownload(function () use ($rows) {
$writer = new CsvWriter('php://output');
$writer->writeRow(['id', 'total']);
foreach ($rows as $row) {
$writer->writeRow([$row->id, $row->total]);
}
}, 'report.csv', ['Content-Type' => 'text/csv']);
If you need to return a BinaryFileResponse or upload to S3, write to php://temp first, then Storage::put() the stream — avoids touching disk.
Random access for reconciliation jobs. For small config-style files, getRow($n) is genuinely useful:
$reader = new CsvReader(config_path('mappings.csv'));
$reader->setHeader($reader->getHeader());
$first = $reader->getRow(1); // 1-based; first data row after the header
Verify indexing against a fixture the first time you adopt the package — the interplay of setHeader() and row numbers is the part developers most often get wrong (see below).
Compose with Laravel validation. A custom rule that opens the file, checks the header, and returns a message is cleaner than validating an uploaded CSV by sniffing bytes:
Validator::extend('csv_header', function ($attr, $file, $params) {
$reader = (new CsvReader($file->getRealPath()))->setHeader([]);
return $reader->getHeader() === explode(',', $params[0]);
});
Testing. Use tmpfile() or Storage::path() with RefreshDatabase; the package does real I/O, so mock the factory rather than the class if you want to avoid disk touches in unit tests.
The default $escapedBy is '', not '\\'. This is the biggest source of "why does this CSV parse differently than Excel/PHP?" bugs. If your input was written with fputcsv() defaults ('\\'), pass it explicitly:
new CsvReader($path, new CsvOptions(',', '"', '\\'));
Omitting it makes the reader treat \" as literal characters instead of an escape. Since PHP 8.4 the language default flipped to '' too, but you should still be explicit — different inputs need different escapes.
getTotalRows() and getRows() scan the file. They are implemented by reading to the EOF. Never call them per-iteration in a loop, and never do getRows(0, getTotalRows()) on a large file — just iterate.
Header semantics are opt-in and row offsets are subtle. The reader does not skip the header unless you call setHeader(). When it is set, iteration yields associative arrays and skips the header line; getRow()/getRows() are 1-based and their relationship to the header (whether row 1 is the header or the first data row) is the thing to check against a fixture before shipping. Don't assume symmetry with fgetcsv.
Encoding is byte-transparent. The package does not detect UTF-8 BOM, UTF-16, or Windows-1252. A leading BOM will end up in the first header cell ("id"). Strip it yourself before constructing the reader, or post-trim the header:
$reader->setHeader(array_map(fn($h) => ltrim($h, "\xEF\xBB\xBF"), $reader->getHeader()));
Convert encodings to UTF-8 upstream — this is a "does not do it for you" library.
CsvWriter opens in truncating mode. Constructing it against an existing path overwrites. CsvFile lazily creates the writer on the first writeRow(), so reading an existing file with CsvFile is safe — but if you use CsvWriter twice on the same path you will clobber the first result. For appends, reuse one writer instance or target php://temp and copy the final file.
Column count is not validated. writeRow([1, 2, 3]) will happily produce ragged CSV. Enforce count($row) === count($header) yourself; a CsvException from a row mismatch is not thrown here.
Line endings and quoting. Output uses \n via fputcsv; Windows consumers (especially legacy Excel) sometimes want CRLF. Also, with $escapedBy = '' the writer quotes RFC 4180-style ("a,b" and doubled quotes). If a downstream system expects backslash escapes, pass '\\' to the writer too — matching reader and writer escape settings matters as much as the delimiter.
Delimiters are single-byte. PHP's fgetcsv/fputcsv limitation. A multibyte separator like • will be truncated to its first byte — split manually if you need one.
No file locking. Concurrent writes (queue workers, parallel imports) can interleave. Wrap the file with flock() around your own handle, or write to a unique temporary path and rename() atomically.
Extension points are thin. There is no plugin system and few hooks: subclass AbstractCsv for custom line handling, extend CsvFile to add app-level readAssoc()/writeBatch() helpers, or wrap the reader in a generator that trims cells and normalizes empty strings to null. Most teams treat this package as a low-level primitive and build a thin import/export layer around it rather than extending it directly.
Debugging tip. When parsing looks wrong, dump the raw line with fgetcsv(fopen($path,'r'), 0, $delim, $enclosure, $escape) using the same four options — the mismatch between reader and writer options (especially escape) will show immediately.
How can I help you explore Laravel packages today?