0

Reducing allocations — avoiding unnecessary object creation in hot paths

Expert5 min read·php-15-010
performance

Concept

Lazy loading and lazy collections defer work until results are actually needed. Instead of loading all data upfront, lazy approaches load on demand — improving memory usage and potentially skipping work entirely for data that's never accessed.

PHP generators for lazy iteration: yield produces values one at a time. A generator function returns a Generator object that produces the next value only when asked. This is the fundamental lazy primitive in PHP — processing a million-row database result set with a generator uses constant memory, whereas ->get() loads all rows into memory.

Laravel Lazy Collections (LazyCollection): Wrap generators in a Collection-style API. LazyCollection::make(fn() => yield from generator()) creates a lazy chain. Operations like filter, map, take, skip, chunk are lazy — no data is loaded until you iterate. Converting to a regular Collection (->collect()) materializes it.

cursor() vs get() in Eloquent:

  • Model::all() / ->get(): Loads ALL records into PHP memory as a Collection of Model instances.
  • ->cursor(): Returns a LazyCollection that fetches records one at a time via a generator. Memory: O(1) vs O(n).
  • ->chunk(1000): Loads 1000 records at a time. Good balance — less memory than ->get(), simpler code than ->cursor().
  • ->chunkById(1000): Like chunk but uses WHERE id > last_id instead of LIMIT/OFFSET — avoids performance degradation on large offsets.

Lazy property initialization: PHP 8.4 lazy objects allow deferred construction of object properties until first access.

Code Example

php
<?php
declare(strict_types=1);

// Generator — lazy row-by-row processing
function readUsersFromDb(\PDO $pdo): \Generator
{
    $stmt = $pdo->query("SELECT * FROM users ORDER BY id");
    while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
        yield $row; // one row at a time — O(1) memory
    }
}

foreach (readUsersFromDb($pdo) as $user) {
    sendEmail($user); // constant memory regardless of user count
}

// Eloquent cursor — lazy collection over all users
User::cursor()->each(function(User $user) {
    sendEmail($user);
});
// Memory: one User model at a time (+ query result set in driver buffer)

// LazyCollection API
use Illuminate\Support\LazyCollection;

$results = LazyCollection::make(function() {
    foreach (file('/var/log/app.log') as $line) {
        yield $line;
    }
})
->filter(fn($line) => str_contains($line, 'ERROR'))
->map(fn($line) => trim($line))
->take(100)  // stop after first 100 errors — lazy, reads only what it needs
->collect(); // materialize to a Collection

// chunkById — efficient large-table processing
User::where('active', true)
    ->chunkById(500, function(\Illuminate\Support\Collection $users) {
        foreach ($users as $user) {
            processUser($user);
        }
    });
// 500 rows per query, uses WHERE id > last_id to avoid offset slowdown

// Lazy vs eager — memory comparison
$allUsers = User::all();    // SELECT * — all 1M users in memory: ~2GB
$lazyUsers = User::cursor(); // lazy — processes one at a time: ~1MB