jwage/easy-csv
Easy CSV is a lightweight PHP library for reading and writing CSV files. It wraps league/csv with a simple API, letting you parse rows into associative arrays keyed by header and write arrays back to CSV with minimal code. Ideal for imports, exports, and data migrations.
EasyCSV is two classes and no dependencies: EasyCSV\Reader and EasyCSV\Writer. That is the whole surface area, so there is nothing to configure, publish, or register.
composer require jwage/easy-csv
The package is archived (last release April 2019, targets PHP 7.2+). It still runs fine on modern PHP in practice because it is a thin wrapper over fgetcsv/fputcsv, but treat it as vendored code you own rather than a maintained dependency. Before reaching for it, ask whether league/csv (already an indirect dependency in many Laravel stacks) covers the same ground — for anything beyond trivial read/write, it does.
First use case: a one-off import. Put the file somewhere Laravel can see it and iterate rows:
use EasyCSV\Reader;
$reader = new Reader(storage_path('app/imports/users.csv'));
while (($row = $reader->getRow()) !== false) {
// $row is an associative array keyed by the header row
User::create([
'name' => $row['name'],
'email' => $row['email'],
]);
}
Look first at the header behaviour: Reader consumes the first line as headers and combines it with every subsequent row, so $row['email'] works out of the box. getHeaders() returns them if you need to introspect; getAll() slurps the whole file into an array (handy in tests and tinker, dangerous in production).
Wrap it in a service, not in a controller. The classes have no Laravel awareness, so the useful work is the mapping layer around them.
namespace App\Services;
use EasyCSV\Reader;
class UserCsvImporter
{
public function import(string $path): int
{
$reader = new Reader($path);
$count = 0;
while (($row = $reader->getRow()) !== false) {
if (! $this->isValid($row)) {
continue; // or collect into an error bag
}
User::updateOrCreate(
['email' => $row['email']],
['name' => $row['name']]
);
$count++;
}
return $count;
}
}
Streaming loop over getAll(). getAll() holds every row in memory; a 200k-row file will hurt. The while loop above is constant-memory and is the pattern you should default to for anything user-uploaded.
Export side with Writer. The writer accepts associative rows and emits the header line from the first row's keys:
use EasyCSV\Writer;
$writer = new Writer(storage_path('app/exports/users.csv'));
$writer->writeRow(['id' => 1, 'name' => 'Jeremy', 'email' => '[email protected]']);
$writer->writeRows($users->map->only(['id', 'name', 'email'])->all());
unset($writer); // releases the handle
Because the header line is derived from the first row, be deliberate: write one explicit header row first if you want stable column order regardless of data.
Artisan commands are the natural home. A php artisan users:import {path} command gives you progress output, exit codes, and a place to wrap the loop in a transaction or a batch counter. For long imports, dispatch a job per file and let the queue handle retries — but chunk the loop yourself (e.g. DB::transaction every 500 rows) since EasyCSV gives you no batching.
Use it in tests as a fixture helper. Building a CSV in a test with Writer is less noise than hand-writing heredocs, and getAll() on a small fixture is perfectly safe:
Storage::fake('local');
(new Writer(Storage::disk('local')->path('t.csv')))->writeRows([...]);
$this->artisan('users:import', ['path' => Storage::disk('local')->path('t.csv')]);
Bind it for consistency. If you use it in several places, register a small CsvFactory in a service provider that returns configured Reader/Writer instances — it makes it a one-line swap to league/csv later, which is exactly the exit strategy an archived package deserves.
fgetcsv helper is a credible replacement."\xEF\xBB\xBFemail"), silently breaking $row['email']. Strip it before constructing the Reader or from the first header key.while (($row = $reader->getRow()) !== false) — strict comparison matters. An empty trailing line can surface as [null] or [''] rather than false; guard with if (empty(array_filter($row, fn($v) => $v !== null && $v !== ''))) continue;.array_combine results rather than a clear exception. Validate the header row yourself against an expected column list before trusting the output.($filePath, $delimiter = ',', $enclosure = '"', $escape = '\\'). Tab- or semicolon-delimited files need the second argument; there is no named-option array, so positional precision matters.rename() on success if anyone depends on the artifact.\xEF\xBB\xBF yourself; if the input is Windows-1252, mb_convert_encoding the rows before mapping.try/catch and rethrow as something your app understands (ValidationException, a domain exception) so callers get a useful message.How can I help you explore Laravel packages today?