- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
Call file() on a 1M-row CSV and PHP builds a million strings before the first email check runs. In plenty of Laravel apps, that email check lives inside an Artisan command's handle(), and so does every other business rule for the import, including the "skip rows with no name" patch someone added after a bad Monday. Then product asks for the same import from an upload form, and the rules can't leave the command without copy-paste.
The command usually looks like this:
public function handle(): int { $lines = file($this->argument('path')); foreach (array_slice($lines, 1) as $line) { [$email, $name, $country] = str_getcsv($line, ',', '"', ''); if (! filter_var($email, FILTER_VALIDATE_EMAIL)) { continue; } Customer::create(compact('email', 'name', 'country')); } return self::SUCCESS; } The problems are all visible. file() reads the entire file into an array, so the whole CSV sits in memory before any row is checked. create() inserts one record per call and returns a model instance, and every call goes through the model lifecycle events. And the validation rules belong to an Artisan command, which is the one place no other caller can reach them.
The fix below moves the rules into a plain ImportCustomers use case. A generator feeds it rows, and a batch-insert port does the writing. The code targets PHP 8.3+, because it uses typed class constants. The snippets skip namespace, use and declare(strict_types=1) lines to stay short; put each class in its own PSR-4 file.

One row in flight at a time. The port writes in batches of 500.
The use case: plain PHP, typed results
Start with the inside. The use case knows nothing about files, HTTP or Eloquent. It takes an iterable of rows keyed by record number and returns a summary.
final readonly class NewCustomer { public function __construct( public string $email, public string $name, public string $country, ) {} } enum RowStatus { case Accepted; case Rejected; } Each row produces a RowResult. The constructor is private, so the only ways to build one are the two named constructors, and a rejected row can't carry a customer:
final readonly class RowResult { private function __construct( public int $line, public RowStatus $status, public ?NewCustomer $customer = null, public ?string $reason = null, ) {} public static function accepted(int $line, NewCustomer $c): self { return new self($line, RowStatus::Accepted, $c); } public static function rejected(int $line, string $why): self { return new self($line, RowStatus::Rejected, reason: $why); } } The port is one method. It speaks in domain objects. Nothing in it knows the table's column names:
interface CustomerRepository { /** @param list<NewCustomer> $customers */ public function insertBatch(array $customers): void; } The summary counts everything but keeps only a sample of rejections, so a file full of garbage can't grow it without bound:
final class ImportSummary { public int $accepted = 0; public int $rejected = 0; /** @var list<RowResult> */ public array $samples = []; public function reject(RowResult $r, int $keep): void { $this->rejected++; if (count($this->samples) < $keep) { $this->samples[] = $r; } } } The use case batches accepted rows and flushes them whenever the batch fills. The batch size is a constructor argument with a default of 500, because the right number depends on the database behind the port:
final class ImportCustomers { private const int SAMPLES = 50; public function __construct( private readonly CustomerRepository $customers, private readonly int $batchSize = 500, ) {} /** @param iterable<int, list<?string>> $rows */ public function handle(iterable $rows): ImportSummary { $summary = new ImportSummary(); $batch = []; foreach ($rows as $line => $fields) { $result = $this->check($line, $fields); if ($result->status === RowStatus::Rejected) { $summary->reject($result, self::SAMPLES); continue; } $batch[] = $result->customer; if (count($batch) === $this->batchSize) { $this->flush($batch, $summary); $batch = []; } } if ($batch !== []) { $this->flush($batch, $summary); } return $summary; } private function flush(array $batch, ImportSummary $s): void { $this->customers->insertBatch($batch); $s->accepted += count($batch); } } The rules live in one private method on the same class:
/** @param list<?string> $fields */ private function check(int $line, array $fields): RowResult { if (count($fields) !== 3) { return RowResult::rejected($line, 'expected 3 columns'); } [$email, $name, $country] = array_map( fn (?string $v): string => trim((string) $v), $fields, ); if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) { return RowResult::rejected($line, 'invalid email'); } if ($name === '') { return RowResult::rejected($line, 'missing name'); } if (preg_match('/^[A-Z]{2}$/', $country) !== 1) { return RowResult::rejected($line, 'bad country code'); } $customer = new NewCustomer( strtolower($email), $name, $country, ); return RowResult::accepted($line, $customer); } Every row comes back as a RowResult, accepted with a NewCustomer or rejected with a reason. The only state that lives across rows is two counters, at most 50 samples and at most one batch of pending customers. That ceiling is what the memory test further down checks.
The driving adapter: a generator over fgetcsv
The PHP manual describes generators as a way to feed a foreach "without having to build an array in memory ahead of time". That is the whole job of the CSV side: hand over one record before reading the next.
Most streaming-CSV examples reach for SplFileObject. Check the calendar first. The Deprecations for PHP 8.6 RFC deprecates SplFileObject::fgetcsv(), fputcsv(), setCsvControl() and getCsvControl(), and that poll closed 25 yes, 5 no, 15 abstain. The SplFileObject::fgetcsv manual page already lists "8.6.0: This method has been deprecated", and PHP 8.6.0 RC5 went out for testing on 5 November 2026. The global fgetcsv() carries no such entry, so the adapter uses a plain stream:
/** @implements IteratorAggregate<int, list<?string>> */ final readonly class CsvRows implements IteratorAggregate { public function __construct(private string $path) {} public function getIterator(): Generator { $h = fopen($this->path, 'rb'); if ($h === false) { throw new RuntimeException("Cannot open {$this->path}"); } try { $n = 0; while (($row = $this->read($h)) !== false) { $n++; if ($n === 1 || $row === [null]) { continue; // header, or a blank line } yield $n => $row; } } finally { fclose($h); } } /** @param resource $h */ private function read($h): array|false { // escape '' = no proprietary escape, no 8.4 deprecation return fgetcsv($h, null, ',', '"', ''); } } Two details come straight from the manual. Since PHP 8.4, relying on the default $escape value is deprecated, so the call passes '' explicitly, which also disables PHP's non-standard escape mechanism. And a blank line comes back as an array holding a single null, not as an error, which is why the loop skips [null].
The key $n is the record number, header included, so a rejection that says "line 48211" points at the 48,211th record. It drifts from the physical line number only when a quoted field contains a newline.
Testing without a database
The port has one method, so the fake is a dozen lines:
final class InMemoryCustomerRepository implements CustomerRepository { /** @var list<NewCustomer> */ public array $saved = []; public int $batches = 0; public function insertBatch(array $customers): void { $this->batches++; array_push($this->saved, ...$customers); } } The PHPUnit tests pass plain arrays and inline generators, so there is no CSV fixture and no RefreshDatabase:
final class ImportCustomersTest extends TestCase { public function test_it_reports_why_rows_fail(): void { $repo = new InMemoryCustomerRepository(); $rows = [ 2 => ['alice@example.com', 'Alice', 'DE'], 3 => ['not-an-email', 'Bob', 'US'], 4 => ['carol@example.com', '', 'FR'], 5 => ['dave@example.com', 'Dave', 'Germany'], ]; $s = (new ImportCustomers($repo))->handle($rows); self::assertSame(1, $s->accepted); self::assertSame(3, $s->rejected); $first = $repo->saved[0]; self::assertSame('alice@example.com', $first->email); self::assertSame( ['invalid email', 'missing name', 'bad country code'], array_map(fn (RowResult $r) => $r->reason, $s->samples), ); } } The second test goes in the same class and pins the batch boundary: 1,001 rows must become 500, 500 and a final flush of 1. That trailing flush is an easy one to forget in a hand-rolled batch loop, and here a unit test with no database catches it.
public function test_it_flushes_in_batches_of_500(): void { $repo = new InMemoryCustomerRepository(); $rows = (function () { for ($i = 2; $i <= 1002; $i++) { yield $i => ["u$i@example.com", "User $i", 'PT']; } })(); $s = (new ImportCustomers($repo))->handle($rows); self::assertSame(1001, $s->accepted); self::assertSame(3, $repo->batches); } Measure the memory instead of claiming it
To check the streaming claim, generate a big file and run the use case against a repository that only counts. Storing a million objects in the fake would only tell you how big the fake got.
<?php // php bin/gen.php 1000000 > customers.csv $n = (int) ($argv[1] ?? 1_000_000); $out = fopen('php://stdout', 'wb'); fputcsv($out, ['email', 'name', 'country'], ',', '"', ''); for ($i = 1; $i <= $n; $i++) { $email = $i % 50 === 0 ? 'broken' : "user$i@example.com"; fputcsv($out, [$email, "User $i", 'DE'], ',', '"', ''); } <?php // php bin/import.php customers.csv declare(strict_types=1); require __DIR__ . '/../vendor/autoload.php'; final class CountingCustomerRepository implements CustomerRepository { public int $rows = 0; public function insertBatch(array $customers): void { $this->rows += count($customers); } } $import = new ImportCustomers(new CountingCustomerRepository()); $summary = $import->handle(new CsvRows($argv[1])); printf( "accepted=%d rejected=%d peak=%.1f MiB\n", $summary->accepted, $summary->rejected, memory_get_peak_usage(true) / 1048576, ); The counts follow from the generator: every 50th row is broken, so a million rows gives accepted=980000 rejected=20000. The peak figure depends on your PHP build, and I haven't run this myself, so read it off your own run. memory_get_peak_usage(true) reports the real memory allocated from the system; without true you get only what PHP's own emalloc() handed out.
The number to watch is the difference between runs. Generate 100,000 rows, then 1,000,000, and compare the two peaks. With the ceilings above, nothing in the pipeline grows per row, so the figure should barely move. If it climbs with the row count, something is accumulating, and the counting repository rules out the storage layer as the culprit.

Three callers, one use case. The rules live in the middle; each door only translates.
Same use case, three doors
Inside Laravel, the driven adapter uses the query builder, which can insert several records at once from an array of arrays. Eloquent never hydrates a model in the loop:
final class DbCustomerRepository implements CustomerRepository { public function insertBatch(array $customers): void { DB::table('customers')->insert(array_map( fn (NewCustomer $c) => [ 'email' => $c->email, 'name' => $c->name, 'country' => $c->country, ], $customers, )); } } A batch of 500 with three columns binds 1,500 parameters in one statement. Check that against your driver's limit: SQLite's default host-parameter cap is 32,766 since 3.32.0, and was 999 before it, so on an old SQLite construct the use case with batchSize: 300. Duplicate emails are a job for a unique index. If you'd rather skip them than fail the batch, insertOrIgnore exists, but the docs warn it can ignore other errors too, depending on the engine.
Bind the port once in a service provider:
$this->app->bind( CustomerRepository::class, DbCustomerRepository::class, ); The Artisan command, renamed so it doesn't clash with the use case class, shrinks to translation:
final class ImportCustomersCommand extends Command { protected $signature = 'customers:import {path}'; public function handle(ImportCustomers $import): int { $path = $this->argument('path'); $s = $import->handle(new CsvRows($path)); $this->info(sprintf( 'accepted=%d rejected=%d', $s->accepted, $s->rejected, )); return self::SUCCESS; } } The upload endpoint calls the same use case on the uploaded file's path. The mimes rule inspects the file's contents, not only its name:
final class CustomerImportController { public function __invoke( Request $request, ImportCustomers $import, ): JsonResponse { $request->validate([ 'file' => ['required', 'file'<...
