heller / simple-csv
A package to make working with CSV files as convenient and simple as possible
Requires
- php: ^8.3
Requires (Dev)
- laravel/pint: ^1.29
- pestphp/pest: ^4.7
- rector/rector: ^2.5
This package is auto-updated.
Last update: 2026-08-17 14:51:53 UTC
README
Make dealing with CSV data as easy and comfortable as possible.
Reads local files, URLs and Google Spreadsheets through one fluent API. Every
read is generator based, so a file of any size costs the same memory as a single
row — unless you explicitly ask for the whole thing with toArray().
Requirements
PHP 8.3 or higher.
Installation
composer require heller/simple-csv
Reading
use Heller\SimpleCsv\Csv; Csv::read('data.csv')->toArray(); Csv::read('https://example.com/data.csv')->toArray(); // Spreadsheet URLs are rewritten to their CSV export automatically Csv::read('https://docs.google.com/spreadsheets/d/ABC123/edit')->toArray();
An unreadable path throws a RuntimeException rather than returning an empty
result, so a typo in a filename cannot look like an empty import.
Delimiter
Csv::read('data.csv')->delimiter(';')->toArray();
Escaping
Fields are parsed the RFC 4180 way: a quote inside a quoted field is doubled,
and a backslash is just a character. Some producers, notably MySQL's
SELECT ... INTO OUTFILE, escape with a backslash instead:
Csv::read('dump.csv')->escape('\\')->toArray();
Encoding
Files that are not UTF-8 are converted while reading. Anything iconv knows
works as a name, Windows-1252 covers most Excel exports:
Csv::read('export.csv')->encoding('Windows-1252')->toArray();
The conversion runs as a stream filter, so it costs nothing per row. Without
it, non-ASCII characters come back as invalid UTF-8 and anything downstream
that expects valid UTF-8 — toJson(), a database write — fails on them.
Header mapping
mapToHeaders() uses a row of the CSV as the keys for every data row. The
header row itself is never returned as data.
$rows = Csv::read('data.csv')->mapToHeaders()->toArray(); foreach ($rows as $row) { echo $row['columnname']; // instead of $row[3] }
Pass a row number if the header is not the first row:
Csv::read('data.csv')->mapToHeaders(3)->toArray();
Pass an array to supply your own header names. No row is consumed, so every line in the file is treated as data:
Csv::read('data.csv')->mapToHeaders(['id', 'name', 'email'])->toArray();
Read the header without reading the file:
Csv::read('data.csv')->getHeaderRow(); // ['Foo', 'Bar', 'Baz']
getHeaderRow() returns the header as it appears in the file — skipColumns()
is not applied to it.
Ragged rows
Rows with a different column count than the header keep their header keys.
Missing values become null, surplus values keep their column index:
// id,name,mail // 1,Ada // 2,Bob,b@x.de,extra ['id' => '1', 'name' => 'Ada', 'mail' => null] ['id' => '2', 'name' => 'Bob', 'mail' => 'b@x.de', 3 => 'extra']
A UTF-8 BOM — written by Excel and Google Sheets — is stripped, so the first header name is usable as a key.
Mapping to objects
By default each row becomes a stdClass, so you can use property access:
Csv::read('data.csv')->mapToObject()->toArray();
Pass a class name to map onto your own type. Values are assigned to properties whose names match the column, other columns are ignored:
Csv::read('data.csv') ->mapToObject(CsvRow::class) ->filter(fn (CsvRow $row) => $row->isValid()) ->toArray();
Column names are normalized to valid property names when mapping to objects: a
column Starts At (UTC) becomes $row->starts_at_utc. Note the difference to
mapToHeaders(), which keeps the original names as array keys.
mapToObject() implies mapToHeaders(), you do not need to call both.
Skipping
Rows and columns are numbered from 1. Both methods take a single value or an
array, and skipColumns() also accepts column names.
Csv::read('data.csv') ->skipRows(1) ->skipColumns([2, 4, 'columnname']) ->toArray();
skipRows() is independent of mapToHeaders() — the header row is skipped in
addition to whatever you list, in any call order.
Rows where every column is empty are returned by default. Drop them with:
Csv::read('data.csv')->skipEmptyRows()->toArray();
Filtering
The callback receives the row after mapping, so it gets an array or an object depending on what you configured. Filtering happens while reading, which keeps it cheap on large files.
Csv::read('data.csv') ->mapToHeaders() ->filter(fn ($row) => $row['column'] !== 'foo') ->toArray();
Getting the data out
$csv = Csv::read('data.csv')->mapToHeaders(); $csv->toArray(); // array of all rows $csv->toJson(); // JSON string of all rows $csv->first(); // first row, or null if there is none $csv->count(); // number of rows, with filter and skips applied
toArray() and toJson() hold the entire file in memory. For anything large,
process row by row instead — this is memory constant and works on files with
millions of records:
Csv::read('data.csv') ->mapToObject(CsvRow::class) ->each(function (CsvRow $row) { // import or handle the row however you like });
Writing
Csv::make($rows)->toFile('out.csv')->write();
write() replaces the file. Pass header names to get them as the first row:
Csv::make([['Ada', 'Berlin']]) ->withHeaders(['name', 'city']) ->toFile('out.csv') ->write(); // name,city // Ada,Berlin
Associative rows are put into header order regardless of the order of their keys, and a column a row does not carry is written empty:
Csv::make([['city' => 'Berlin', 'name' => 'Ada'], ['name' => 'Bob']]) ->withHeaders(['name', 'city']) ->toFile('out.csv') ->write(); // name,city // Ada,Berlin // Bob,
Objects are written by their public properties, so anything read with
mapToObject() can be written straight back out.
Appending
append() keeps the existing contents and does not repeat the header row. When
no headers are set, the header already in the file defines the column order:
Csv::make([['city' => 'Hamburg', 'name' => 'Bob']]) ->toFile('out.csv') ->append();
If the file is missing or empty, append() writes it like write() would,
header included.
Inserting at a position
insertAt() puts rows in front of an existing record. Records are counted from
1 and the header is record 1, so the first data row is position 2:
// Col1,Col2 // A,A // B,B Csv::make([['NEW', 'NEW']])->toFile('data.csv')->insertAt(3); // Col1,Col2 // A,A // NEW,NEW // B,B
Everything after the insert is copied byte for byte, so quoting and spacing of untouched records survive. The file is rebuilt next to itself and moved into place in one step, which means a crash mid-write cannot leave a half-written file behind. Memory stays constant regardless of file size — inserting into a 1M row file costs about 2 MB.
A position past the end appends. A missing or empty file is written from
scratch, like write().
Delimiter and line endings
Csv::make($rows)->delimiter(';')->toFile('out.csv')->write();
Rows end with \n. Excel on Windows expects \r\n, and needs a UTF-8 BOM to
read anything outside ASCII correctly:
Csv::make($rows)->delimiter(';')->crlf()->bom()->toFile('export.csv')->write();
bom() only applies to write(). append() and insertAt() leave a file
that already has content alone.
Known limitations
- Output is always UTF-8. Only reading converts between encodings.
- Existing records can be inserted in front of, but not changed or removed.
Development
composer test # pest composer lint # pint composer bench # 1M row benchmark, generates its own fixture
License
MIT. See LICENSE.