Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

PHP Generators Streaming Large Data Sets

By Kokil Thapa | Last reviewed: September 2026

Exporting 500,000 order rows crashes your PHP script at 512 MB. The fix is not always a bigger server. PHP generators streaming large data sets process one record at a time and keep memory flat. I've hit this on production Laravel apps during nightly CSV exports and legal document batch jobs. This guide shows how generators work, when they beat arrays, and how to wire them to PDO, Eloquent, and HTTP responses on PHP 8.3 through 8.5.

What are PHP generators and how do they stream large data sets?

A generator is a function that returns an iterator via yield. Each call to next() runs the function until the next yield. Then execution pauses. State is preserved between yields. You never materialise the full collection.

Compare that to array_map() or fetchAll(). Those load every row into RAM first. On a custom enterprise application with two million audit log rows, that difference ends the job before it starts.

Array vs Generator MemoryfetchAll()All rows in RAMMemory grows with countGenerator yieldOne row at a timeFlat memory curvePHP Generators Streaming Large Data SetsIterator protocol: foreach reads one yield per loopWorks on PHP 8.3, 8.4, and 8.5
PHP generators streaming large data sets keep memory flat while arrays scale with row count

Basic generator syntax

The simplest pattern reads a large file line by line. No full file load required.

<?php
declare(strict_types=1);

function readLargeFile(string $path): Generator
{
    $handle = fopen($path, 'rb');
    if ($handle === false) {
        throw new RuntimeException("Cannot open {$path}");
    }

    try {
        while (($line = fgets($handle)) !== false) {
            yield rtrim($line, "\r\n");
        }
    } finally {
        fclose($handle);
    }
}

foreach (readLargeFile('/var/exports/orders.csv') as $line) {
    /* process one line */
}

PHP treats any function containing yield as a generator. The return type hint Generator documents intent. Use finally so file handles close even when the loop breaks early.

Generator delegation with yield from

yield from delegates to another iterable or generator. That lets you compose pipelines without nesting loops.

function filterActiveUsers(Generator $users): Generator
{
    foreach ($users as $user) {
        if ($user['status'] === 'active') {
            yield $user;
        }
    }
}

function streamUsers(): Generator
{
    yield from filterActiveUsers(fetchUsersFromDb());
}

The official PHP manual documents generators and the iterator protocol in detail. See the PHP generator language reference for return values and send() semantics.

When should you use PHP generators instead of loading arrays?

Generators shine when data volume is unknown or too large for RAM. They are a poor fit when you need random access, repeated full scans, or small fixed lists.

ApproachMemoryBest forAvoid when
fetchAll() / arrayO(n) — grows with rows< 5,000 rows, sorting in PHPMillion-row exports
Generator + yieldO(1) — one row bufferedCSV export, ETL, log parsingNeed count() or index access
array_chunk() in loopO(chunk size) per batchBatch DB updatesSingle-pass streaming to HTTP
Laravel cursor()O(1) with lazy hydrationEloquent model streamingHeavy eager loads per row
Redis queue jobsSpread across workersLong async processingSimple synchronous download

On the Nepal Gift Card platform, order history exports use generators. Loading every gift-card transaction into an array would spike memory during peak sales season.

Rule of thumb: if the dataset can exceed available PHP memory minus overhead, stream it. Check your PHP-FPM memory limits before assuming 512 MB is enough.

  • Streaming CSV or JSON downloads to the browser
  • Nightly reconciliation files from payment gateways
  • Import pipelines that validate row-by-row
  • Log aggregation across rotated files
  • Read-only reporting where SQL aggregation is not possible

How do you stream database results with PDO and Laravel?

Generators alone do not help if PDO buffers the entire result set server-side. You must disable buffering for true streaming from MySQL 9.7 or MariaDB 12.3.

PDO Streaming PipelineMySQLPDOUnbufferedGeneratoryield rowCSV outPer-Row Processing Loop1. fetch() one associative row2. yield to foreach consumer3. fputcsv() or transform4. repeat until no rows
Unbuffered PDO plus PHP generator yield streams MySQL rows without loading the full result set

PDO with MYSQL_ATTR_USE_BUFFERED_QUERY disabled

<?php
function streamOrders(PDO $pdo): Generator
{
    $pdo->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, false);

    $stmt = $pdo->query(
        'SELECT id, total, created_at FROM orders WHERE status = "paid"'
    );

    try {
        while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
            yield $row;
        }
    } finally {
        $stmt->closeCursor();
        $pdo->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, true);
    }
}

Always restore buffered mode after streaming. Leaving it off breaks later queries on the same connection. Always close the cursor in finally.

MySQL documents cursor and result-set behaviour in the MySQL 9.7 cursor reference. Unbuffered mode holds the read lock until the cursor closes.

Laravel Eloquent cursor() and lazy()

Laravel 12 and 13 expose cursor() for generator-style iteration over Eloquent models. It uses PHP generators internally and fetches one row at a time.

use App\Models\Order;

function exportPaidOrders(): Generator
{
    foreach (Order::query()
        ->where('status', 'paid')
        ->select(['id', 'total', 'created_at'])
        ->cursor() as $order) {
        yield [
            'id' => $order->id,
            'total' => $order->total,
            'date' => $order->created_at->toDateString(),
        ];
    }
}

lazy() fetches in chunks of 1000 by default. Use it when each model triggers accessors or small relation lookups. Use cursor() for bare exports.

On booking systems like Adventure Third Pole Trek, I've streamed trek booking rows for accounting exports. Selecting only needed columns avoids hydrating full models.

Watch for N+1 queries inside the loop. Eager loading does not work the same way with cursors. Join the data in SQL or accept denormalised export queries.

Chunking for writes vs generators for reads

chunkById() suits batch updates. Generators suit read-only streaming. Do not mix them blindly.

  1. Use cursor() or a PDO generator for read/export paths.
  2. Use chunkById() when updating 50,000 rows in batches of 500.
  3. Queue a job per chunk when work exceeds HTTP timeout.
  4. Log progress every N rows for observability.

How do you stream CSV exports and HTTP responses with generators?

The real win is sending data to the browser while it is still being read from MySQL. The user gets bytes immediately. Memory stays low on both sides.

HTTP Streamed CSV ExportControllerGeneratoryield rowsphp://outputHeaders: text/csv, no-cache, attachmentBrowser receives file incrementallyNo temp file on disk required
Stream CSV directly from a PHP generator to php://output for immediate browser downloads

Symfony and plain PHP streamed response

<?php
function streamCsvResponse(Generator $rows, array $headers): void
{
    header('Content-Type: text/csv; charset=UTF-8');
    header('Content-Disposition: attachment; filename="export.csv"');
    header('Cache-Control: no-cache');

    $out = fopen('php://output', 'wb');
    fputcsv($out, $headers);

    foreach ($rows as $row) {
        fputcsv($out, $row);
    }

    fclose($out);
}

Disable output buffering before streaming. In PHP-FPM, call ob_end_flush() in a loop until no buffer remains. Laravel's StreamedResponse wraps this pattern cleanly.

use Symfony\Component\HttpFoundation\StreamedResponse;

return new StreamedResponse(function () {
    $out = fopen('php://output', 'wb');
    foreach (exportPaidOrders() as $row) {
        fputcsv($out, $row);
    }
    fclose($out);
}, 200, [
    'Content-Type' => 'text/csv',
    'Content-Disposition' => 'attachment; filename="orders.csv"',
]);

Long streams can exceed nginx or Apache proxy timeouts. Raise fastcgi_read_timeout or move very large exports to a queued job that writes to object storage. Then email a signed download link.

Validate exported JSON with a JSON formatter during development. Broken escaping shows up fast in small samples.

Combining generators with API pagination

External APIs paginate results. Wrap pagination in a generator so callers still see a flat iterable.

function streamApiPages(callable $fetchPage): Generator
{
    $page = 1;

    do {
        $response = $fetchPage($page);
        foreach ($response['data'] as $item) {
            yield $item;
        }
        $page++;
    } while ($response['meta']['has_more'] === true);
}

Add exponential backoff on rate-limit responses. I've used this pattern for payment reconciliation on production apps. Each page yields rows without storing prior pages.

What mistakes break PHP generator memory savings in production?

Generators do not magically fix bad code. Several habits reintroduce memory spikes or lock tables for minutes.

Generator Gotcha Decision TreeUsing generators?Buffered PDO?Fix: unbufferedCollecting rows?Fix: process inlineN+1 in loop?Fix: SQL joinFlat memory achievedLog every 10k rowsRun load tests before peak traffic
Buffered queries, row collection, and N+1 loops undo PHP generators streaming large data sets

Mistake 1: collecting into an array inside the loop

/* BAD — defeats the generator */
$all = [];
foreach (streamOrders($pdo) as $row) {
    $all[] = transform($row);
}
return $all;

Process or write each row immediately. If you need aggregation, use SQL GROUP BY or a rolling counter variable.

Mistake 2: keeping unbuffered connections open too long

An open unbuffered read blocks writes to InnoDB tables in some configurations. Stream during off-peak windows. Or replicate to a read replica for heavy exports.

Mistake 3: ignoring time and memory limits

Generators fix memory. They do not fix CPU or wall-clock time. Set set_time_limit(0) only in CLI exports. For HTTP, use queues.

Run load tests with k6 against export endpoints before launch. A flat memory graph means nothing if the request times out at 60 seconds.

Mistake 4: serialising generator output

Generators are not serialisable. Do not pass them to Redis or cache drivers. Materialise to a file path or job payload instead.

Static analysis catches some misuse. Add PHPStan level 9 to CI so return types stay honest.

Monitoring and testing

Log peak memory with memory_get_peak_usage(true) at the end of CLI exports. Compare against the same export using fetchAll(). The difference should be orders of magnitude.

For WooCommerce-scale catalogues, see WooCommerce speed optimisation for large catalogs. WordPress often loads full post arrays where a custom SQL stream would suffice.

On grocery platforms like Quick And Easy Nepalese Grocery, delivery-zone order exports run as Artisan commands. Generators plus unbuffered PDO keep nightly cron inside 128 MB.

Key Takeaways

  • Use yield and yield from to iterate large files, API pages, and query results without building arrays.
  • Disable PDO buffered queries when streaming MySQL; restore the setting and close cursors in finally.
  • Prefer Laravel cursor() for lean exports and lazy() when you need chunked model hydration.
  • Stream CSV to php://output with output buffering disabled; queue jobs for exports over proxy timeouts.
  • Never collect yielded rows into a second array — process, write, or aggregate inline.
  • Load-test export endpoints and log peak memory so flat RAM claims hold under production traffic.

People Also Ask

Do PHP generators work with PHP 8.5?

Yes. Generators have been stable since PHP 5.5. PHP 8.5 adds no breaking changes to yield semantics. Typed generator return values and strict types work as expected. All examples here run on PHP 8.3 through 8.5 with Composer 2.10.

How much memory do PHP generators save?

Memory stays near O(1) relative to row count when you stream correctly. A one-million-row export might use 30–80 MB with a generator versus 800 MB or more with fetchAll(). Exact numbers depend on row width and PHP overhead.

Can Laravel queues use generators directly?

No. Queue jobs serialise their payload. Pass a query scope or file path to the job instead. Let the worker build the generator inside handle(). That pattern keeps Redis payloads small and jobs retry-safe.

Are generators faster than arrays?

Not always. Generators reduce memory and often start output sooner. Per-row overhead can be slightly higher than bulk array iteration. Choose generators when memory or streaming latency matters, not raw microsecond speed on small datasets.

Ship exports that survive production traffic

PHP generators streaming large data sets are the right tool when arrays would blow memory or delay downloads. Pair generators with unbuffered PDO or Laravel cursors, stream directly to HTTP or disk, and avoid collecting rows mid-pipeline. That is the pattern I rely on for exports on Laravel apps serving real clients in Nepal and abroad.

Need help refactoring a failing export or designing a streaming API? Review our testing and optimisation services or API development work. For full-stack delivery including deployment and LEMP stack setup, see web development services. About me covers 15+ years of production PHP work. When exports outgrow single-server streaming, test data pipelines and Redis-backed queues scale the workload. Ongoing support catches memory regressions before your clients do. Contact us to audit your export path.

Frequently Asked Questions

A PHP generator is a function that uses yield to return values one at a time without building a full in-memory collection. Each call to next() runs the function until the next yield, then pauses while preserving state. Unlike fetchAll() or array_map(), which load every row into RAM first, generators keep memory near O(1) relative to row count. Pair them with unbuffered PDO queries or Laravel cursor() to stream MySQL rows, CSV lines, or API pages with flat RAM usage on PHP 8.3 through 8.5.

Near O(1) versus O(n). A one-million-row export might use 30–80 MB with a generator versus 800 MB or more with fetchAll(), depending on row width and PHP overhead.

Yes. Generators have been stable since PHP 5.5. PHP 8.5 adds no breaking changes to yield semantics, and typed Generator return values work as expected.

Use generators when data volume is unknown or too large for available PHP memory minus overhead. They suit CSV exports, ETL pipelines, log parsing, and read-only reporting where SQL aggregation is not possible. Avoid them when you need random access, repeated full scans, count(), or index access on the full dataset. As a rule of thumb, if the dataset can exceed your PHP-FPM memory limit, often 512 MB on shared hosting, stream it. Small fixed lists under roughly 5,000 rows are fine in arrays.

Generators alone are not enough if PDO buffers the entire result set server-side. Disable buffering with PDO::MYSQL_ATTR_USE_BUFFERED_QUERY set to false before querying, then fetch rows one at a time inside a generator loop. Always close the cursor in a finally block and restore buffered mode to true afterward, because leaving it off breaks later queries on the same connection. Unbuffered mode holds a read lock until the cursor closes, which matters on MySQL 9.7 and MariaDB 12.3 under load.

Both Laravel 12 and 13 expose these for generator-style iteration. cursor() uses PHP generators internally and fetches one Eloquent row at a time, making it ideal for lean exports where you select only needed columns. lazy() fetches in chunks of 1000 by default, which suits cases where each model triggers accessors or small relation lookups. Use cursor() for bare CSV exports. Use lazy() when chunked model hydration reduces per-row overhead. Eager loading does not work the same way with cursors, so join data in SQL instead.

Disable output buffering first, calling ob_end_flush() in a loop until no buffer remains under PHP-FPM. Open php://output, write headers with fputcsv(), then iterate your generator and fputcsv() each row immediately. Set Content-Type to text/csv, Content-Disposition to attachment, and Cache-Control to no-cache. In Laravel, wrap the same pattern in Symfony StreamedResponse so bytes reach the browser while MySQL rows are still being read. Never collect yielded rows into a second array before writing.

No. Queue jobs serialise their payload, and generators are not serialisable. Passing one to Redis or a cache driver will fail. Instead, pass a query scope, filter parameters, or file path to the job. Let the worker build the generator inside handle(). That keeps Redis payloads small and makes jobs retry-safe. For exports that exceed HTTP or proxy timeouts, queue a job that writes to disk or object storage, then email a signed download link.

yield from delegates iteration to another iterable or generator, letting you compose streaming pipelines without nesting foreach loops. For example, streamUsers() can yield from filterActiveUsers(fetchUsersFromDb()) so filtering and fetching remain separate reusable functions. The official PHP manual documents generators and the iterator protocol, including return values and send() semantics. Use yield from when you want flat, readable export pipelines where each stage transforms or filters one row at a time.

Collecting rows into an array inside the loop defeats the generator entirely. Keeping unbuffered PDO connections open too long can block InnoDB writes. Ignoring wall-clock and CPU limits means a flat memory graph still fails at a 60-second proxy timeout. Serialising generator output to Redis or cache drivers fails because generators are not serialisable. N+1 queries inside cursor loops reintroduce database overhead. Run load tests with k6 against export endpoints and log peak memory with memory_get_peak_usage(true) to confirm savings hold under real traffic.

With default buffered mode, PDO loads the entire result set into PHP memory before your generator loop runs, which eliminates the memory benefit of yield. Setting MYSQL_ATTR_USE_BUFFERED_QUERY to false streams one row at a time from MySQL 9.7 or MariaDB 12.3. The trade-off is that the connection holds a read lock until you close the cursor, so stream during off-peak windows or use a read replica for heavy exports. Always restore buffered mode and close the cursor in a finally block.

chunkById() suits batch writes such as updating 50,000 rows in batches of 500. Generators suit read-only streaming paths like CSV exports and HTTP downloads. Do not mix them blindly. Use cursor() or a PDO generator for read and export paths. Use chunkById() when mutating large tables in controlled batches. Queue a job per chunk when work exceeds HTTP timeout. Log progress every N rows for observability during long-running Artisan commands.

Not always. Generators reduce memory and often start output sooner, which improves perceived latency for browser downloads. Per-row overhead can be slightly higher than bulk array iteration because execution pauses and resumes at each yield. Choose generators when memory pressure or streaming latency matters, not raw microsecond speed on small datasets under roughly 5,000 rows. On production Laravel apps, the win is surviving a 500,000-row export within a 512 MB PHP-FPM limit, not beating array speed on tiny lists.

Wrap pagination in a generator so callers see a flat iterable without storing prior pages. Loop from page one, call your fetch function, yield each item from the response data array, increment the page, and continue while meta indicates has_more is true. Add exponential backoff on rate-limit responses. I have used this pattern for payment gateway reconciliation on production apps. Each page yields rows immediately while prior pages are discarded from memory.

Generators fix memory but not wall-clock time. Raise fastcgi_read_timeout for moderate streams, or move very large exports to a queued job that writes to object storage and emails a signed download link. For CLI cron exports, set_time_limit(0) is acceptable. On platforms like Quick And Easy Nepalese Grocery, delivery-zone order exports run as Artisan commands where generators plus unbuffered PDO keep nightly cron inside 128 MB. Load-test export endpoints before launch so flat RAM claims hold under production traffic.

Share this article

0 Comments

Leave a comment

Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

Quick Contact Options
Choose how you want to connect me: