Pop PHP
The Toolkit

CSV

pop-csv moves data between a PHP array and CSV text. One object, Pop\Csv\Csv, holds both representations — the array and the string — and converts in whichever direction you ask for. It handles the file-versus-string ambiguity for you, so an import endpoint that might be handed either does not need to work out which it has.

BASH
composer require popphp/pop-csv

Reading CSV#

The constructor takes CSV data and unserialize() turns it into an array of rows:

PHP
use Pop\Csv\Csv;

$csv = new Csv("first_name,last_name\nBob,Smith\nJane,Smith\n");

$rows = $csv->unserialize();
// [['first_name' => 'Bob', 'last_name' => 'Smith'], ['first_name' => 'Jane', 'last_name' => 'Smith']]

The header row becomes the array keys, and every value comes back as a string — CSV carries no types.

What the constructor argument means depends on its shape. A non-string is PHP data. A string is a file path only when it contains .csv or .tsv and a file exists there, in which case it is read immediately; any other string is raw CSV text, parsed later.

The static shortcuts skip the second call:

PHP
use Pop\Csv\Csv;

$csv  = Csv::loadFile(__DIR__ . '/../var/import.csv');   // Csv object, already unserialized
$csv  = Csv::loadString("a,b\n1,2\n");                    // the same, from a string
$rows = Csv::getDataFromFile(__DIR__ . '/../var/import.csv'); // the array directly

All of them take the same options array the constructor does.

Csv::isValid() is a structural sanity check for a file you did not produce. It returns false for an empty string, and for one whose data rows do not all have the first row's column count:

PHP
use Pop\Csv\Csv;

$string = "a,b\n1,2\n3,4\n";

if (Csv::isValid($string)) {
    $rows = Csv::loadString($string)->getData();
}

It checks structure and nothing else — not encoding, not which delimiter is in use.

Everything above holds the whole result in memory. readRowsFromFile() returns a generator instead, yielding one row at a time:

PHP
use Pop\Csv\Csv;

foreach (Csv::readRowsFromFile(__DIR__ . '/../var/large-import.csv') as $row) {
    // one row at a time; memory stays flat however long the file is
}

Csv::getRowCountFromFile() streams the same way when all you want is a count — for a progress bar before the import starts. Pass ['headers' => true] to subtract the header row; without it the header counts as a row, so a four-record file reports 5.

A missing file throws Pop\Csv\Exception from both — "Error: The file '...' does not exist."

Writing CSV#

Going the other way, the constructor takes an array of rows and serialize() returns the CSV text. The keys of the first row become the header:

PHP
use Pop\Csv\Csv;

$csv = new Csv([
    ['first_name' => 'Bob',  'last_name' => 'Smith'],
    ['first_name' => 'Jane', 'last_name' => 'Smith'],
]);

echo $csv->serialize();
TEXT
first_name,last_name
Bob,Smith
Jane,Smith

writeToFile(), outputToHttp() and casting the object to a string all serialize first if that has not happened yet, so echo $csv; produces the same text without the explicit call.

PHP
use Pop\Csv\Csv;

$rows = [
    ['first_name' => 'Bob',  'last_name' => 'Smith'],
    ['first_name' => 'Jane', 'last_name' => 'Smith'],
];

$csv = new Csv($rows);

$csv->writeToFile(__DIR__ . '/../var/export.csv');
$csv->outputToHttp('my-file.csv');

outputToHttp() sends Content-Disposition: attachment by default. Pass false as the second argument for inline, and extra headers as the third. Csv::writeDataToFile() and Csv::outputDataToHttp() do the same in one static call.

For a file too large to build in memory, append instead. The target has to exist already:

PHP
use Pop\Csv\Csv;

Csv::writeDataToFile([['first_name' => 'Bob', 'last_name' => 'Smith']], __DIR__ . '/../var/export.csv');

Csv::appendRowToFile(__DIR__ . '/../var/export.csv', ['first_name' => 'John', 'last_name' => 'Smith']);
Csv::appendDataToFile(__DIR__ . '/../var/export.csv', [['first_name' => 'Amy', 'last_name' => 'Jones']]);

Every append re-reads the target's header row and compares it against the new row's keys, throwing Pop\Csv\Exception ("Error: The new data's columns do not match the CSV files columns.") on a mismatch. The comparison includes order, so the right keys in the wrong order are rejected. Pass false as the last argument to skip the check.

The Blank/Template methods write the header row alone, for a file someone fills in and sends back. They derive it from the first data row's keys, so one representative row is still needed:

PHP
use Pop\Csv\Csv;

$rows = [['first_name' => 'Bob', 'last_name' => 'Smith']];

Csv::writeTemplateToFile($rows, __DIR__ . '/../var/template.csv');   // first_name,last_name
Csv::outputTemplateToHttp($rows, 'template.csv');

writeBlankFile() and outputBlankFileToHttp() are the instance equivalents, and both throw Pop\Csv\Exception ("Error: The data has not been set.") when called before any data is on the object.

Options and Delimiters#

One options array covers both directions and is accepted by the constructor and by every static method.

Option Default What it does
delimiter , The field separator. "\t" gives TSV; any single character works
enclosure " The quoting character
escape " The character that escapes an enclosure inside a value
fields true Write the header row. false writes data rows alone
exclude [] Column names to leave out
include [] Column names to keep, dropping everything else
newline true Allow newlines inside a cell. false collapses them
limit 0 Truncate each cell to this many characters. 0 is no limit
length 0 Maximum line length read per row when parsing. 0 is no limit
escapeFormulas false Prefix spreadsheet-formula cells with a single quote
map [] Flatten a nested array to one of its keys
columns [] Join a list of nested arrays on one of their keys

Tab-separated data is the delimiter option and nothing else. A path ending in .tsv is auto-detected as a file exactly as .csv is, but the delimiter is still passed on the way back in:

PHP
use Pop\Csv\Csv;

$rows = [['first_name' => 'Bob', 'last_name' => 'Smith']];

$csv = new Csv($rows, ['delimiter' => "\t"]);
$csv->writeToFile(__DIR__ . '/../var/export.tsv');

$loaded = Csv::loadFile(__DIR__ . '/../var/export.tsv', ['delimiter' => "\t"]);

escapeFormulas matters on any export carrying user-supplied text. A cell beginning =, +, - or @ is a live formula when the file opens in Excel, Google Sheets or LibreOffice; with the option on, such a cell gets a leading single quote that the spreadsheet strips on display and never evaluates:

PHP
use Pop\Csv\Csv;

$csv = new Csv([['note' => '=SUM(A1:A10)']], ['escapeFormulas' => true]);

echo $csv->serialize(); // note\n'=SUM(A1:A10)\n

Pass escapeFormulas when the CSV is destined for a spreadsheet, so a cell beginning =, +, - or @ is written as text.

Working with Nested Data#

CSV is flat and the data you export usually is not. A nested array has to be reduced to one cell first, and two options do that. map handles a single nested array: name the column and the key inside it. columns handles a list of them: name the column and the key to pull from each entry, and the values are joined into one cell.

PHP
use Pop\Csv\Csv;

$users = [
    [
        'id'       => 1,
        'username' => 'testuser',
        'country'  => ['name' => 'United States', 'code' => 'US'],
        'roles'    => [
            ['id' => 1, 'name' => 'Admin'],
            ['id' => 2, 'name' => 'Staff'],
        ],
    ],
];

$csv = new Csv($users, [
    'map'     => ['country' => 'code'],
    'columns' => ['roles' => 'name'],
]);

echo $csv->serialize();
TEXT
id,username,country,roles
1,testuser,US,"Admin,Staff"

country collapsed to US and roles to Admin,Staff — quoted, because the joined value contains the delimiter.

Give both options a list of rows, even for a single record — the outer value is always the collection.

Both options are narrow on purpose — they cover a related record's label, not arbitrary nesting. Anything else, flatten yourself before constructing the Csv.

Newlines inside a cell survive by default, quoted, which is valid CSV that some consumers handle badly. ['newline' => false] collapses each to a space, and ['limit' => n] truncates every cell to n characters.

The Csv object's own accessors and the append variants' full parameter lists go past what this page covers — see the pop-csv README.

See Also#