portphp/csv
CSV reader and writer for PortPHP import/export workflows. Stream CSV files with configurable delimiters, enclosures, and headers, and plug into PortPHP pipelines for efficient data migration, ETL, and bulk imports in PHP applications.
portphp/csv is a thin adapter that plugs CSV files into Port's reader/writer pipeline. It ships two classes — Port\Csv\CsvReader and Port\Csv\CsvWriter — and nothing else. Everything interesting (mapping, validation, conversion, batching) lives in portphp/port and portphp/steps, so budget your reading time accordingly.
composer require portphp/csv
First place to look: the installed source, since the implementation is only a few hundred lines.
vendor/portphp/csv/src/Port/Csv/CsvReader.php
vendor/portphp/csv/src/Port/Csv/CsvWriter.php
Check the class ... extends line at the top of each file — older tags extend \SplFileObject directly, newer tags may wrap league/csv. That single detail determines several of the gotchas below.
Minimal end-to-end script:
<?php
use Port\Csv\CsvReader;
use Port\Csv\CsvWriter;
use Port\Workflow;
$reader = new CsvReader('orders.csv', ','); // filename, delimiter, enclosure, escape
$reader->setHeaderRowNumber(0); // row 0 becomes the array keys
$writer = new CsvWriter('orders-out.csv');
$writer->setDelimiter(',');
$writer->setEnclosure('"');
$workflow = new Workflow($reader);
$workflow->addWriter($writer);
$workflow->process();
That is the canonical first use case: a nightly CLI job that reshapes a supplier/export CSV into a canonical file. Once reading and writing work, drop a MappingStep into the workflow and you have the real 90% case.
Header-driven rows. With setHeaderRowNumber(0), current() returns associative arrays (['sku' => 'A1', 'qty' => '3']) instead of numeric ones. That is what Port's mapping and converter steps expect. Use the second argument to force names when the file has no header or you want to rename on the way in:
$reader->setHeaderRowNumber(2, ['sku', 'qty', 'price']); // skip two junk rows, then assume names
Normalize column order on output. CsvWriter::writeItem() writes fputcsv($stream, $item), i.e. values in the current key order of the array. If a mapping step adds or removes keys, column order drifts between rows. Pin it with a template:
$template = array_fill_keys(['sku', 'qty', 'price'], null);
$workflow->addStep(new CallbackStep(function (array $item) use ($template) {
return array_merge($template, $item); // stable order, missing keys become null
}));
Read → transform → write with Port steps.
use Port\Steps\Step\MappingStep;
use Port\Steps\Step\ValueConverterStep;
use Port\Steps\Step\CallbackStep;
$workflow->addStep(new MappingStep([
'product_code' => 'sku',
'quantity' => 'qty',
]));
$workflow->addStep(new ValueConverterStep([
'qty' => 'intval',
'price' => fn ($v) => (float) str_replace(',', '.', $v),
]));
$workflow->addStep(new CallbackStep(function (array $item) {
if ($item['qty'] <= 0) {
throw new \Port\Exception\ValidationException(...); // skipped, workflow keeps going
}
return $item;
}));
Fan-out to several sinks. addWriter() is variadic in practice — CSV for humans, a DB writer for the real table, both fed from the same pass:
$workflow->addWriter(new CsvWriter('report.csv'));
$workflow->addWriter($doctrineWriter);
Encoding conversion without touching the data. If the source is Latin-1 (very common with exports from Windows tooling), attach a PHP stream filter instead of a converter per field:
$reader->setStreamFilter('convert.iconv.ISO-8859-1/UTF-8');
The writer has the same method for the opposite direction. This is the cheapest fix and keeps the CSV semantics (delimiters, quotes) intact.
Large files. The reader iterates lazily, so a workflow over a 2 GB file is fine provided you don't call steps that buffer. If you must batch (bulk inserts, chunked uploads), buffer inside a callback step and flush on count($buffer) === 500. Don't call count($reader) to size the batch — see gotchas.
Integrating into Laravel. Treat it as a console concern: a queued job or an artisan command that constructs the workflow and calls process(). There is no service provider and nothing to configure. Bind the reader/writer only if you test the pipeline in isolation; a plain new CsvReader(storage_path('app/in.csv')) is usually clearer.
The writer's default delimiter is ;, the reader's is ,. This asymmetry is the single most common "my output is broken" report. Always call $writer->setDelimiter(',') explicitly.
setHeaderRowNumber() must be called before iteration. It rewinds, reads the header, then advances. Calling it after the workflow has started iterating leaves the pointer in an inconsistent place, and you'll silently reprocess or skip row 0.
Whitespace and BOM end up in your keys. With an \SplFileObject-based reader nothing is trimmed: a UTF-8 BOM turns sku into \xEF\xBB\xBFsku, and "sku " becomes a key with a trailing space. Both break every mapping lookup. Defend with a one-off filter or override the headers:
$reader->setColumnHeaders(array_map('trim', $reader->getColumnHeaders()));
(Strip the BOM with setStreamFilter or a preg_replace on the first header only.)
Duplicate header names silently lose a column. Associative arrays keyed by header mean ["price", "price"] keeps only the second value. Rename duplicates before setHeaderRowNumber() by passing explicit headers.
count() on the reader is O(n) and moves the pointer. CsvReader implements CountableReader; counting walks the file. Never use it for progress bars on big files unless you accept a full extra pass, and always rewind() afterwards.
SplFileObject::SKIP_EMPTY is set on the reader. Blank lines are dropped, but a line consisting of a single empty field can also be treated as empty depending on flags. If empty fields are meaningful in your dataset, verify behavior on a 3-row sample before trusting it.
Type juggling happens in fputcsv, not in your step. null writes as an empty string, false as "", true as 1, floats with the current locale's decimal separator unless you cast to string first. Money and booleans are the usual casualties — format them in a converter step.
No header row is written for you. CsvWriter writes exactly the arrays it is given. Emit the header yourself as the first item (a callback step, or $writer->writeItem($headers) inside a small subclass that overrides prepare()), or your file will be headerless.
Excel compatibility. Excel prefers \r\n and a UTF-8 BOM. The writer opens the file with 'w' in its constructor and closes it in finish(), so subclass and write "\xEF\xBB\xBF" in prepare() if you need BOM. Line endings are not exposed — that's an extension point, not a config option.
The writer truncates the target file on construction. There is no append mode. For incremental runs, write to a per-run temp file and concatenate, or subclass and open with 'a'.
Escape character. If your tag delegates to league/csv, the escape argument defaults to \ and has been deprecated in recent league/csv releases because it deviates from RFC 4180. Pass an empty string / '' if you see doubled quotes or mangled backslashes in fields. If your tag extends \SplFileObject, control it via the fourth constructor argument.
Extending. The cleanest extension points are subclassing CsvWriter (override writeItem, prepare, finish — all protected) and implementing Port\Writer for a decorator that adds headers or filters rows. Avoid subclassing CsvReader if the installed version extends \SplFileObject; the iterator internals are fragile, and a CallbackStep upstream is almost always the better place to put the logic.
How can I help you explore Laravel packages today?