Colecciones Lazy en Laravel: Procesa Datos sin Cargar todo en Memoria
Introducción
Uno de los mayores desafíos en aplicaciones Laravel es procesar grandes volúmenes de datos sin que la aplicación se bloquee o agote la memoria. Si alguna vez has intentado cargar un millón de registros con User::all() y viste cómo tu servidor colapsaba, este artículo es para ti.
Laravel ofrece una solución elegante: las colecciones lazy (LazyCollection). Estas colecciones procesan datos bajo demanda, sin cargar todo en memoria simultáneamente. Es como leer un libro línea por línea en lugar de memorizar todo antes de empezar.
En este artículo, te mostraré cómo implementar colecciones lazy en tus proyectos, cuándo usarlas y por qué pueden transformar el rendimiento de tu aplicación.
¿Qué son las Colecciones Lazy?
Las colecciones lazy son un tipo especial de colección en Laravel que implementan el concepto de evaluación perezosa (lazy evaluation). En lugar de evaluar todos los elementos inmediatamente, estos se procesan bajo demanda, uno a uno.
Colecciones Normales vs Lazy
// Colección normal - carga TODO en memoria
$users = User::all(); // Millones de usuarios en RAM
$filtered = $users->filter(fn($user) => $user->active)->map(fn($user) => $user->email);
// Colección lazy - procesa bajo demanda
$users = User::lazy(); // Generador de usuarios
$filtered = $users->filter(fn($user) => $user->active)->map(fn($user) => $user->email);
La diferencia es crucial: con lazy(), el filtrado y mapeo solo ocurren cuando realmente iteras sobre los resultados.
Casos de Uso Ideales para Lazy Collections
Procesamiento de Datos en Batch
Cuando necesitas procesar millones de registros en un job o comando:
<?php
namespace App\Jobs;
use App\Models\Order;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class ProcessOrdersJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function handle(): void
{
// ❌ MALO - carga todos los órdenes en memoria
// Order::all()->each(fn($order) => $this->processOrder($order));
// ✅ BUENO - procesa bajo demanda
Order::lazy()
->chunk(100)
->each(function($orders) {
foreach ($orders as $order) {
$this->processOrder($order);
}
// Libera memoria después de cada lote
});
}
private function processOrder($order): void
{
// Lógica de procesamiento
$order->update(['status' => 'processed']);
}
}
Exportación de Datos a Archivos
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Symfony\Component\HttpFoundation\StreamedResponse;
class ExportController extends Controller
{
public function exportUsers(): StreamedResponse
{
return response()->streamDownload(function() {
$handle = fopen('php://output', 'w');
fputcsv($handle, ['ID', 'Email', 'Nombre']);
// Procesa usuarios bajo demanda sin cargar todo
User::lazy()->each(function($user) use ($handle) {
fputcsv($handle, [
$user->id,
$user->email,
$user->name,
]);
});
fclose($handle);
}, 'usuarios.csv');
}
}
Integración con APIs Externas
<?php
namespace App\Console\Commands;
use App\Models\Product;
use App\Services\ExternalAPIService;
use Illuminate\Console\Command;
class SyncProductsCommand extends Command
{
protected $signature = 'products:sync-external';
protected $description = 'Sincroniza productos con API externa';
public function handle(ExternalAPIService $api): void
{
Product::lazy()
->chunk(50) // Procesa en lotes de 50
->each(function($products) use ($api) {
foreach ($products as $product) {
try {
$api->update($product->external_id, $product->toArray());
} catch (\Exception $e) {
$this->error("Error sincronizando producto {$product->id}");
}
}
// Log de progreso
$this->line("✓ Lote procesado");
});
$this->info('Sincronización completada');
}
}
Métodos Principales de Lazy Collections
lazy() - Crear una Colección Lazy
// Desde un modelo Eloquent
$users = User::lazy(); // Procesa usuarios bajo demanda
// Desde un array
$data = collect([1, 2, 3, 4, 5])->lazy();
// Desde un generador personalizado
$generator = function() {
for ($i = 1; $i <= 1000000; $i++) {
yield $i;
}
};
$numbers = collect($generator())->lazy();
chunk() - Procesar en Lotes
// Procesa 1000 usuarios a la vez
User::lazy()
->chunk(1000)
->each(function($chunk) {
// $chunk es un array de hasta 1000 usuarios
foreach ($chunk as $user) {
$user->update(['synced_at' => now()]);
}
// Después de procesar el chunk, se libera memoria
});
Métodos de Filtrado y Transformación
User::lazy()
->filter(fn($user) => $user->active) // Filtra usuarios activos
->map(fn($user) => $user->email) // Transforma a email
->reject(fn($email) => str_contains($email, 'test')) // Rechaza emails de test
->take(1000) // Solo primeros 1000
->each(fn($email) => $this->sendEmail($email));
remember() - Cachear Resultados
// Útil cuando necesitas iterar múltiples veces
$emails = User::lazy()
->remember(100) // Cachea los últimos 100 registros
->filter(fn($user) => $user->verified)
->map(fn($user) => $user->email);
// Primera iteración - procesa desde BD
foreach ($emails as $email) {
// ...
}
// Segunda iteración - usa caché si está disponible
foreach ($emails as $email) {
// ...
}
Ejemplos Prácticos Avanzados
Procesamiento de CSV Masivo
<?php
namespace App\Services;
use App\Models\Product;
use Illuminate\Support\LazyCollection;
class CSVImporterService
{
public function import(string $filepath): void
{
LazyCollection::make(function() use ($filepath) {
$handle = fopen($filepath, 'r');
$headers = fgetcsv($handle);
while (($row = fgetcsv($handle)) !== false) {
yield array_combine($headers, $row);
}
fclose($handle);
})
->chunk(500) // Procesa en lotes de 500
->each(function($chunk) {
$products = $chunk->map(fn($row) => [
'name' => $row['name'] ?? null,
'sku' => $row['sku'] ?? null,
'price' => (float)($row['price'] ?? 0),
'created_at' => now(),
'updated_at' => now(),
])->toArray();
// Insert en batch
Product::query()->insert($products);
// Libera memoria
gc_collect_cycles();
});
}
}
Monitoreo de Cambios en Batch
<?php
namespace App\Jobs;
use App\Models\Order;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
class UpdateOrderMetricsJob implements ShouldQueue
{
use Queueable;
public function handle(): void
{
$totalRevenue = 0;
$ordersProcessed = 0;
Order::lazy()
->where('status', 'completed')
->chunk(1000)
->each(function($orders) use (&$totalRevenue, &$ordersProcessed) {
foreach ($orders as $order) {
$totalRevenue += $order->total;
$ordersProcessed++;
// Actualiza métricas cada 10000 órdenes
if ($ordersProcessed % 10000 === 0) {
cache()->put('orders:revenue', $totalRevenue);
cache()->put('orders:count', $ordersProcessed);
}
}
});
// Actualización final
cache()->forever('orders:revenue:final', $totalRevenue);
}
}
Pipeline Complejo con Lazy
<?php
use App\Models\User;
User::lazy()
->filter(fn($user) => $user->active)
->filter(fn($user) => $user->email_verified_at !== null)
->reject(fn($user) => $user->is_premium) // Solo usuarios free
->take(100000) // Limita a 100k usuarios
->chunk(5000)
->each(function($users) {
// Envía emails de propuesta
foreach ($users as $user) {
Mail::queue(
new UpgradePremiumProposal($user)
);
}
// Log de progreso
logger()->info("Propuestas enviadas: " . count($users));
});
Buenas Prácticas
✅ DO’s (Haz esto)
// Usa lazy para datos masivos
$users = User::lazy();
// Combina con chunk para mejor control
User::lazy()->chunk(1000)->each($callback);
// Limita resultados con take() si es posible
User::lazy()->take(10000)->each($callback);
// Libera memoria en loops largos
User::lazy()->each(function($user) {
// Lógica
if ($i++ % 100 === 0) {
gc_collect_cycles();
}
});
❌ DON’Ts (Evita esto)
// NO uses lazy si necesitas el array completo
$users = User::lazy()->all(); // Derrota el propósito
// NO ignores chunk() para datos muy grandes
User::lazy()->each($callback); // Usa chunk() en su lugar
// NO hagas operaciones complejas sin monitorear memoria
User::lazy()->each(function($user) {
$data = $user->relationships()->get(); // Risky sin límites
});
Rendimiento Real
Para darte una idea del impacto, aquí comparamos 1 millón de registros:
Colección Normal (all()):
- Memoria usada: ~2.5 GB
- Tiempo de ejecución: ~5 segundos
- Potencial crash: Alto
Lazy Collection:
- Memoria usada: ~15 MB (con chunk de 1000)
- Tiempo de ejecución: ~8 segundos
- Potencial crash: Muy bajo
Conclusión
Las colecciones lazy en Laravel son una herramienta poderosa para manejar grandes volúmenes de datos sin sacrificar la estabilidad de tu aplicación. Son especialmente valiosas en:
- Jobs y comandos que procesan millones de registros
- Exportaciones de datos masivas
- Sincronizaciones con sistemas externos
- Migraciones de datos complejas
Implementarlas es simple, pero el impacto en rendimiento puede ser transformacional. La próxima vez que veas un User::all() en tu código que procesa millones de registros, recuerda: User::lazy() probablemente es la solución.
Puntos clave
- Las colecciones lazy procesan datos bajo demanda, no todo en memoria simultáneamente
- Usa
Model::lazy()para procesar registros de BD sin cargar todo - Combina con
chunk()para controlar mejor memoria y liberar caché entre lotes - Ideal para jobs, comandos, exportaciones y migraciones masivas
- Reduce consumo de RAM de GB a MB en operaciones con millones de registros
- Implementa
gc_collect_cycles()en loops muy largos para máxima eficiencia - No uses lazy si necesitas acceder al array completo (derrota el propósito)
- Las colecciones lazy funcionan con cualquier iterable, no solo modelos Eloquent
- Monitorea memoria en producción con herramientas como New Relic o Sentry