heller/simple-csv

A package to make working with CSV files as convenient and simple as possible

Maintainers

Package info

github.com/lpheller/simple-csv

pkg:composer/heller/simple-csv

Transparency log

Statistics

Installs: 56

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.2.0 2026-08-17 14:18 UTC

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.