keboola/php-csvtable
A PHP library for reading and writing CSV tables, offering an object-oriented API over CSV files with configurable delimiters, enclosures, and escaping. It follows Keboola CSV conventions and provides utilities for manipulating tabular data in PHP applications.
keboola/php-csvtable is a plain Composer library, not a Laravel package — there is no service provider, facade, config file, or publishable asset. Installation is one line and there is nothing to register:
composer require keboola/php-csvtable:^2.2
Autoloading is PSR-4, so the classes are usable immediately. It depends on keboola/csv, which is where the actual reader/writer live; CsvTable is the tabular wrapper over them (columns, rows, header handling).
Where to look first. Skim three files in vendor/ before writing anything:
keboola/php-csvtable/src/CsvTable.php — the whole public surface. It is small; read it top to bottom.keboola/csv/src/CsvReader.php and CsvWriter.php — this is where delimiter/enclosure/escape options are actually defined. CsvTable forwards to these, so the option names you need live here.First use case. An Artisan command that reads a CSV a client dropped into storage, reports what it found, and writes a filtered subset back out:
// app/Console/Commands/InspectCsv.php
public function handle(): int
{
$path = Storage::path('imports/customers.csv');
// Verify the exact factory name against src/CsvTable.php before committing;
// the API is a small set of static constructors + getters + write methods.
$table = \Keboola\CsvTable\CsvTable::fromFile($path);
$this->info('Columns: ' . implode(', ', $table->getColumns()));
$this->info('Rows: ' . count($table->getRows()));
return self::SUCCESS;
}
That is enough to validate the dependency, confirm your PHP version satisfies the constraint, and see the real shape of the data. Everything after that is a matter of putting a boundary around it.
Wrap it behind your own contract from day one. The package is stable but single-vendor and narrow. Do not let CsvTable leak into controllers, jobs, or domain services. Define an interface in your app and implement it with Keboola:
namespace App\Data;
interface CsvTableReader
{
/** @return array{columns: string[], rows: array<int, array<string, string>>} */
public function read(string $absolutePath, array $dialect = []): array;
}
final class KeboolaCsvTableReader implements CsvTableReader
{
public function read(string $absolutePath, array $dialect = []): array
{
$table = \Keboola\CsvTable\CsvTable::fromFile($absolutePath);
return [
'columns' => $table->getColumns(),
'rows' => $table->getRows(),
];
}
}
Bind it once in a DataServiceProvider. Swapping to league/csv later becomes a one-class change instead of a grep across the codebase.
Resolve storage paths, never pass URLs. The library takes filesystem paths. In Laravel that means Storage::path($relative) for the local disk, or $file->getRealPath() for an uploaded UploadedFile. For S3 or other non-local disks, stream down to a tmpfile() or a temporary local path first — the abstraction has no concept of a stream wrapper you control.
Configure the dialect explicitly, per integration. Do not rely on defaults. Client CSVs routinely differ on delimiter (; for European Excel exports), enclosure, and BOM. Centralize the mapping in config rather than scattering arguments:
// config/csv.php
return [
'imports' => [
'customers' => ['delimiter' => ';', 'enclosure' => '"'],
'orders' => ['delimiter' => ',', 'enclosure' => '"'],
],
];
Pass the resolved options into your adapter, and keep the array in your own vocabulary so the underlying option names stay an implementation detail.
Keep it on the queue for anything user-facing. Import and export are I/O- and CPU-bound and scale horizontally. A queued job with an explicit $timeout and a memory ceiling is the right home:
final class ImportCustomersCsv implements ShouldQueue
{
public int $timeout = 120;
public function handle(CsvTableReader $reader): void
{
$data = $reader->read(Storage::path($this->path), config('csv.imports.customers'));
// map into domain objects / upserts here
}
}
Normalize the row shape before it reaches your domain. CsvTable gives you rows keyed by column header (or indexed, depending on how you ask). Whatever the exact accessor, do the coercion yourself: trim, cast numerics, handle empty strings vs null, and validate that every expected header exists. A missing or renamed column is the single most common real-world failure, and it should throw a clear domain exception, not produce nulls.
Test with fixture files, not just mocks. The value of the adapter is that you can write golden-file tests: drop a handful of .csv files in tests/Fixtures, run them through the adapter, assert on the normalized output. Cover at minimum: quoted fields containing commas, quoted fields containing newlines, a UTF-8 BOM, Windows line endings (\r\n), a semicolon-delimited file, and an empty file. Those seven fixtures will catch almost everything.
Wire it into export flows symmetrically. The same adapter pattern works in reverse — build rows from Eloquent collections, write to a temp path, then Storage::putFileAs() or stream the response with response()->download(). Keep the "build rows" step independent of CSV concerns so report logic is testable without touching the filesystem.
The table abstraction materializes the file. This is the biggest operational risk. Rows come back as PHP arrays in memory, so a 200 MB CSV can easily become a multi-gigabyte process. Enforce a size cap at the adapter boundary (filesize() check, reject or route to a chunked path above ~10–20 MB), and set memory_limit on the queue worker explicitly. Do not let "it worked on a small file" become the production contract.
Everything is a string. CSV has no types. Numeric-looking columns are strings, "0" is falsy in PHP, and leading-zero values like postal codes will survive parsing but get destroyed the moment you cast to int. Decide the casting policy per column, not globally.
Encoding is silent when it fails. If the input is ISO-8859-1 or Windows-1252 and you assume UTF-8, you get mojibake rather than an exception. Detect or explicitly configure encoding at the boundary, and if you cannot, run the file through mb_convert_encoding() on the way in. The package does not solve this for you.
BOM breaks the first header. A UTF-8 BOM in front of the first column name yields a header like "\u{FEFF}id" that will never match 'id'. Strip the BOM on ingest, or you will spend an afternoon debugging a column that "looks right."
Header handling is a design decision, not a default. Confirm how the abstraction treats the first line — as header vs data — and whether a headerless file is supported. Write it down in your adapter's docblock. If a client occasionally sends a headerless file, that assumption becomes a support ticket; detect it (e.g. a data row that looks like a header row) or reject it loudly.
Newlines inside quoted fields are legal and will confuse naive consumers. They are handled by the underlying keboola/csv reader, but any downstream process you have that assumes line-per-record (a wc -l, an Excel auto-open, a split script) will break. When re-emitting, keep the enclosure settings consistent with what consumers expect.
No Laravel integration means no auto-cleanup, no health checks, no events. Nothing will tell you the package went stale. Add it to your routine dependency-review cadence — it is a one-line composer update review, but only if someone remembers to do it. The 2.2.0 release was maintenance-only (PHPStan/PHPCS fixes, license file, keboola/csv bump), which is a good sign for stability and a reminder that the release cadence is quiet.
Pin the transitive dependency. 2.2.0 bumped keboola/csv. Commit composer.lock, review that transitive version explicitly on every update, and run your CSV fixture suite after any composer update that touches either package — the reader's edge-case behavior is what your tests are actually protecting.
Watch for duplicate CSV libraries. If anything else in the graph pulls in league/csv or similar, you now have two parsers with subtly different quoting rules. Pick one, and if you must have both, isolate them by boundary and never let data cross between them mid-pipeline.
Extension points are effectively nil — and that is fine. There are no hooks, no macros, no config. Do not subclass CsvTable; use composition through your adapter. If you find yourself wanting to extend it, that is the signal your needs have outgrown it and the adapter has already made the swap cheap.
How can I help you explore Laravel packages today?