Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Csv Laravel Package

goodby/csv

Goodby CSV is a memory-efficient, flexible PHP library for importing and exporting CSV files. It processes rows line by line via callbacks without loading everything into memory, supports multibyte encodings like SJIS-win, EUC-JP, and UTF-8, and is fully unit-tested for enterprise use.

View on GitHub
Deep Wiki
Context7

Getting Started

Install it as a plain Composer dependency — there is no service provider or facade, and none is needed:

composer require goodby/csv

Where to look first. The library is split into two parallel namespaces, each with three classes you actually touch:

Task Classes
Import Goodby\CSV\Import\Standard\Lexer, Interpreter, LexerConfig
Export Goodby\CSV\Export\Standard\Exporter, ExporterConfig

Ignore everything under Goodby\CSV\Import\SQL and the Filter/Observer interfaces until you need them — the Lexer + Interpreter + one closure covers 90% of work.

First use case: an Artisan import command. The canonical pattern is a streaming line-by-line callback, so the file never lands in memory as an array:

// app/Console/Commands/ImportUsers.php  (handle())
$config = new LexerConfig();
$config->setDelimiter(',')
       ->setEnclosure('"')
       ->setFromCharset('SJIS-win')   // omit if the file is already UTF-8
       ->setToCharset('UTF-8')
       ->setIgnoreHeaderLine(true);

$lexer = new Lexer($config);
$interpreter = new Interpreter();

$interpreter->addObserver(function (array $row) use (&$count) {
    User::create(['name' => $row[0], 'email' => $row[1]]);
    $count++;
});

$lexer->parse(storage_path('app/import/users.csv'), $interpreter);
$this->info("Imported {$count} rows");

Run it with php artisan app:import-users. If that works on a 500 MB file with flat memory, you have understood the package.

Implementation Patterns

Wrap the library in a dedicated service, not in your controller. The Lexer/Interpreter API is closure-based and stateful; hiding it behind a typed service keeps your domain clean and makes it swappable later.

// app/Support/Csv/CsvImporter.php
class CsvImporter
{
    public function import(string $path, callable $onRow, array $options = []): int
    {
        $config = (new LexerConfig())
            ->setDelimiter($options['delimiter'] ?? ',')
            ->setFromCharset($options['from'] ?? 'UTF-8')
            ->setToCharset('UTF-8')
            ->setIgnoreHeaderLine($options['skip_header'] ?? true);

        if (isset($options['columns'])) {
            (new InterpreterConfig())->setColumnCount($options['columns']); // strict width check
        }

        $count = 0;
        (new Interpreter())->addObserver(function (array $row) use ($onRow, &$count) {
            $onRow($row);
            $count++;
        });

        (new Lexer($config))->parse($path, $interpreter);
        return $count;
    }
}

Chunked inserts. create() per row means one query per row. Buffer and flush — this is the single biggest performance lever:

$buffer = [];
$interpreter->addObserver(function (array $row) use (&$buffer) {
    $buffer[] = ['name' => $row[0], 'email' => $row[1], 'created_at' => now()];
    if (count($buffer) >= 500) {
        DB::table('users')->insert($buffer);
        $buffer = [];
    }
});
$lexer->parse($path, $interpreter);
DB::table('users')->insert($buffer); // final partial batch

Keep the batch size under your MySQL max_allowed_packet; 500–1000 rows of short text is safe, 5000 with long blobs is not.

Header mapping without a library feature. Goodby only skips the header (setIgnoreHeaderLine) — it does not map names to keys. Do it yourself by peeking at the first line with fgetcsv, then closing over the map:

$map = array_flip(array_map('trim', fgetcsv($fh = fopen($path, 'r')))); fclose($fh);

$interpreter->addObserver(function (array $row) use ($map) {
    $assoc = [];
    foreach ($map as $column => $index) {
        $assoc[$column] = $row[$index] ?? null;
    }
    // validate $assoc with Validator::make($assoc, $rules) here
});

Per-row validation via Laravel's validator. Because every row is a plain array of strings, this composes naturally:

$validator = Validator::make($assoc, ['email' => 'required|email']);
if ($validator->fails()) {
    $this->line("Row skipped: " . $validator->errors()->first());
    return; // continue importing
}

Queueing. Never dispatch inside the observer for row-level jobs — you'll enqueue tens of thousands of tiny jobs. Either insert synchronously in chunks and then dispatch one follow-up job, or push batched arrays:

if (count($buffer) >= 500) {
    ImportUserChunk::dispatch($buffer); // job receives a plain array, serializes fine
    $buffer = [];
}

Export: streaming downloads. The Exporter iterates the data set and writes incrementally, and it accepts any Traversable, so a generator keeps memory flat on large exports:

$config = (new ExporterConfig())
    ->setDelimiter(',')
    ->setEnclosure('"')
    ->setNewline("\r\n")
    ->setFromCharset('UTF-8')
    ->setToCharset('SJIS-win')   // shift for Japanese Excel users
    ->setUseBom(false);          // BOM only for UTF-8 output

$rows = (function () {
    foreach (Order::cursor() as $order) {
        yield [$order->id, $order->customer_name, $order->total];
    }
})();

return response()->streamDownload(function () use ($config, $rows) {
    (new Exporter($config))->export('php://output', $rows);
}, 'orders-' . now()->format('Ymd') . '.csv');

Encoding normalisation on upload. Users upload unknown encodings. Detect before configuring:

$source = mb_detect_encoding(file_get_contents($path), ['UTF-8', 'SJIS-win', 'EUC-JP'], true) ?: 'UTF-8';

Then pass $source as setFromCharset. Let the library convert; don't iconv the whole file yourself.

Test with Storage::fake() and a two-line fixture. The observer is a pure closure, so assertions are trivial:

Storage::fake('local');
Storage::disk('local')->put('t.csv', "name,email\nJane,[email protected]\n");
$rows = [];
app(CsvImporter::class)->import(Storage::disk('local')->path('t.csv'), function ($r) use (&$rows) { $rows[] = $r; });
$this->assertSame(['Jane', '[email protected]'], $rows[0]);

Gotchas and Tips

The package is frozen at 2016. Last release 2016-02-29, no fixes since. It still runs on PHP 8.x, but expect deprecation notices from fputcsv/fgetcsv default-escape handling on PHP 8.4+, and note there is no escape setter on ExporterConfig to silence them. Pin the exact version in composer.json, keep it behind your CsvImporter/CsvExporter service so a future swap to league/csv is a one-file change, and don't build new enterprise features that depend on internals.

A blank trailing line yields a bogus row. Files ending in \n (i.e. most files) produce a final [null] or [''] callback in some configurations. Always guard:

if ($row === [null] || $row === [''] || $row === false) {
    return;
}

This is the most common "why do I have one extra empty record" bug report.

setIgnoreHeaderLine(true) skips blindly. It does not validate that the header exists or match column names. If the first row might be data (headerless files), you'll silently eat a record. Detect headers by inspecting the raw first line before the Lexer runs.

UTF-8 BOM breaks the first column name. Microsoft Excel writes \xEF\xBB\xBF at the start of the file, which lands inside the first field. Strip it before parsing, or set setIgnoreHeaderLine(true) plus trim the BOM from the first column of every row. Goodby will not do this for you.

Charset conversion is per-field, not per-file. If setFromCharset is wrong, you get mangled text or a \UnexpectedValueException from mb_convert_encoding — not a friendly validation error. Wrap $lexer->parse() in a try/catch in your command and report the offending file, or use mb_check_encoding on a sample first.

Strict mode fails the whole run. InterpreterConfig::setStrict(true) (or LexerConfig::setStrict) throws on ragged rows — great for validating a schema-driven upload, terrible for user-supplied exports from a CRM. Choose per import, not globally.

UploadedFile::getRealPath() can be null. If you parsed the upload before calling store()/move(), the temp file may be gone. Always $file->storeAs('imports', $name) first and parse via Storage::path().

The Lexer keeps the file handle open until parse() returns. Don't call parse() inside a loop over many files while holding other SplFileObject handles; and never run two Lexer::parse() calls concurrently in a single process expecting interleaved output.

Transaction scope is your decision, not the library's. Wrapping the entire import in DB::transaction() makes a 200k-row file a single long lock — usually bad. Prefer per-chunk transactions plus an idempotent strategy (upsert with a unique key) so a mid-file failure can be resumed rather than restarted.

Excel needs \r\n and either a BOM or SJIS-win. Default \n output opens as one giant row in some Excel versions. Set setNewline("\r\n") and pick one encoding strategy: UTF-8 + setUseBom(true), or setToCharset('SJIS-win') with no BOM. Don't mix them.

setDelimiter matters for locale-locked Excel. European installs export semicolons. Detect by counting ; vs , in the first line before instantiating LexerConfig, or accept a --delimiter option on your command.

Extension points are the observer chain and config subclasses. Interpreter::addObserver() accepts multiple callbacks and runs them in registration order — use this to separate concerns (validation observer, persistence observer, counting observer) rather than growing one closure. If you need behaviour changes, subclass LexerConfig/ExporterConfig rather than forking the package.

Missing mbstring fails at runtime, not install time. There's no ext-mbstring constraint enforced in older releases. In slim Docker images add docker-php-ext-install mbstring or the first Japanese-encoded file will throw an unhelpful error.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
dakiquang/laravel-oauth-refresh-lock
woda/laravel-worktrees
dalefranks/ramasite
faghy/laracashbook
roundly-consulting/google-places-for-laravel
roundly-consulting/kubernetes-api-for-laravel
roundly-consulting/plausible-for-laravel
gvp/gvp-laravel-template
metasyncsite/laravel-metasync-client
roundly-consulting/onboarding-for-laravel
andydefer/laravel-notification
adachsoft/cli-process-tester
jhaskell13/laraflow
sebastienheyd/boilerplate-media-manager
ccast/tagixo-primix
pretendtrue/laravel-builder
sawmainek/laravel-scout-tntsearch-driver
tomshaw/laravel-calendar-table
dibakar/laravel-ownership
adachsoft/changelog-linter