Mengimpor file Excel atau CSV berukuran besar di Laravel terlihat sederhana pada awalnya: upload file, baca baris demi baris, lalu simpan ke database. Masalah mulai muncul saat ukuran file membesar, jumlah baris mencapai puluhan atau ratusan ribu, atau setiap baris membutuhkan validasi dan relasi tambahan. Gejalanya biasanya sama: request web timeout, worker PHP kehabisan memori, CPU tinggi, dan database dibanjiri query insert satu per satu.

Solusi yang stabil bukan sekadar menaikkan max_execution_time atau memory_limit. Pendekatan yang lebih tepat adalah memecah proses menjadi unit kerja kecil yang dijalankan di background: simpan file, kirim job ke queue, baca file per chunk, validasi secukupnya, lalu simpan dalam bentuk batch insert. Dengan pola ini, request pengguna tetap cepat, konsumsi memori lebih terkendali, dan kegagalan lebih mudah diisolasi.

Artikel ini fokus pada strategi implementasi di Laravel untuk kasus import besar. Anda bisa menerapkannya baik dengan pembacaan CSV manual maupun dengan bantuan library seperti Laravel Excel. Fokus utamanya bukan pada package, tetapi pada desain proses yang tahan terhadap timeout, hemat memori, dan aman dari duplikasi data.

Kenapa import besar sering timeout atau memory habis?

Ada beberapa bottleneck yang sangat umum pada proses import file besar:

  • Request HTTP melakukan semua pekerjaan. Saat upload selesai, controller langsung membaca seluruh file dan menulis semuanya ke database. Ini membuat request terlalu lama dan rentan timeout di PHP, Nginx, Apache, atau load balancer.
  • Seluruh file dimuat ke memori. Beberapa implementasi membaca semua baris sekaligus ke array. Untuk file besar, konsumsi memori bisa melonjak drastis.
  • Insert per baris. Menjalankan satu query insert untuk setiap row sangat mahal. Jika ada 100.000 baris, berarti ada 100.000 query, belum termasuk validasi dan lookup tambahan.
  • Validasi atau lookup berulang. Misalnya setiap row mengecek apakah email sudah ada dengan query terpisah. Ini menciptakan pola N+1 query pada proses import.
  • Transaksi terlalu besar. Membungkus seluruh import dalam satu transaksi besar bisa membuat lock lama, log transaksi membesar, dan rollback sangat mahal jika gagal di akhir.

Karena itu, pola yang lebih sehat adalah: request hanya menyimpan file dan membuat catatan import, lalu pekerjaan berat dipindahkan ke queue.

Arsitektur proses import yang lebih aman

Alur yang direkomendasikan untuk import file besar di Laravel biasanya seperti ini:

  1. User upload file Excel/CSV.
  2. Aplikasi menyimpan file ke storage.
  3. Aplikasi membuat record imports untuk melacak status: pending, processing, completed, failed.
  4. Controller me-dispatch job queue, misalnya ProcessImportJob.
  5. Job membaca file per chunk, misalnya 500 atau 1000 baris per iterasi.
  6. Setiap chunk divalidasi dan diubah ke format yang siap disimpan.
  7. Data disimpan dengan batch insert atau upsert.
  8. Progress diperbarui agar user bisa melihat status import.

Dengan desain ini, web request tetap singkat. Jika satu chunk gagal, Anda bisa menandai import gagal atau hanya chunk tertentu yang diulang, tergantung desain yang dipilih.

Tabel pelacakan import

Sangat disarankan membuat tabel khusus untuk melacak proses import. Contoh field yang berguna:

  • id
  • file_path
  • status (pending, processing, completed, failed)
  • total_rows
  • processed_rows
  • success_rows
  • failed_rows
  • error_message
  • file_hash untuk deteksi duplikasi file
  • started_at, finished_at

Ini penting bukan hanya untuk monitoring, tetapi juga untuk menghindari user mengunggah file yang sama berulang kali tanpa sadar.

Implementasi dasar: upload lalu proses di queue

Controller sebaiknya tidak memproses file. Tugasnya cukup menyimpan file dan mengirim job.

public function import(Request $request)
{
    $request->validate([
        'file' => ['required', 'file', 'mimes:csv,txt,xlsx,xls'],
    ]);

    $path = $request->file('file')->store('imports');
    $fullPath = storage_path('app/' . $path);
    $hash = hash_file('sha256', $fullPath);

    $import = ImportJob::create([
        'file_path' => $path,
        'file_hash' => $hash,
        'status' => 'pending',
        'processed_rows' => 0,
        'success_rows' => 0,
        'failed_rows' => 0,
    ]);

    ProcessImportJob::dispatch($import->id);

    return response()->json([
        'message' => 'File diterima dan sedang diproses.',
        'import_id' => $import->id,
    ]);
}

Di sisi queue worker, Anda bisa mengatur timeout yang lebih sesuai dibanding request web. Namun, jangan menjadikan timeout worker sebagai alasan untuk tetap memproses file sekaligus. Tujuan utama queue tetap sama: memecah pekerjaan besar menjadi pekerjaan kecil.

Konfigurasi queue yang perlu diperhatikan

Pastikan worker queue berjalan stabil dan punya parameter yang realistis. Misalnya:

php artisan queue:work --queue=imports --timeout=120 --tries=3 --memory=512

Beberapa catatan penting:

  • timeout harus lebih besar dari waktu rata-rata satu job/chunk, tetapi jangan terlalu besar hingga job macet terlalu lama.
  • tries berguna untuk retry jika ada kegagalan sementara, misalnya koneksi database putus.
  • memory membantu worker di-restart jika konsumsi memori naik terus.

Jika memakai Supervisor atau sistem process manager lain, pastikan konfigurasi restart worker juga benar. Banyak kasus timeout sebenarnya adalah worker yang tidak berjalan atau tidak me-reload kode terbaru.

Chunk reading: jangan baca seluruh file sekaligus

Kunci utama menghemat memori adalah membaca file sedikit demi sedikit. Jika file Anda CSV, pembacaan manual sering kali cukup dan lebih transparan untuk dikendalikan. Jika file XLSX, Anda bisa memakai library yang mendukung pembacaan bertahap. Prinsipnya sama: ambil sejumlah baris, proses, simpan, lanjutkan ke chunk berikutnya.

Contoh pembacaan CSV per chunk

class ProcessImportJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(public int $importId) {}

    public function handle(): void
    {
        $import = ImportJob::findOrFail($this->importId);
        $import->update(['status' => 'processing', 'started_at' => now()]);

        $path = storage_path('app/' . $import->file_path);
        $handle = fopen($path, 'r');

        if (! $handle) {
            throw new RuntimeException('File tidak bisa dibuka');
        }

        $header = fgetcsv($handle);
        $chunkSize = 1000;
        $rows = [];

        while (($row = fgetcsv($handle)) !== false) {
            $rows[] = array_combine($header, $row);

            if (count($rows) >= $chunkSize) {
                $this->processChunk($rows, $import);
                $rows = [];
            }
        }

        if (! empty($rows)) {
            $this->processChunk($rows, $import);
        }

        fclose($handle);

        $import->update([
            'status' => 'completed',
            'finished_at' => now(),
        ]);
    }

    protected function processChunk(array $rows, ImportJob $import): void
    {
        // proses validasi dan simpan batch
    }
}

Jika menggunakan Laravel Excel, konsep serupa biasanya diwujudkan melalui chunk reading dan queue per chunk. Itu sangat membantu untuk file Excel asli, tetapi tetap pastikan desain validasi dan query Anda efisien. Package tidak otomatis memperbaiki pola query yang buruk.

Menentukan ukuran chunk

Tidak ada angka universal. Mulailah dari 500 atau 1000 baris, lalu ukur:

  • Jika memori masih tinggi, kecilkan chunk.
  • Jika query ke database terlalu sering, bisa naikkan chunk sedikit.
  • Jika setiap row butuh transformasi berat, pakai chunk lebih kecil.

Ukuran chunk yang tepat tergantung bentuk data, jumlah kolom, validasi, serta apakah ada relasi atau lookup tambahan.

Batch insert dan upsert: kurangi jumlah query

Setelah data per chunk siap, hindari menyimpan satu per satu. Kumpulkan row yang valid dalam array lalu kirim sebagai satu query insert atau upsert. Ini biasanya memberi peningkatan performa paling terasa.

protected function processChunk(array $rows, ImportJob $import): void
{
    $now = now();
    $payload = [];
    $failed = 0;

    foreach ($rows as $row) {
        if (empty($row['email']) || empty($row['name'])) {
            $failed++;
            continue;
        }

        $payload[] = [
            'name' => trim($row['name']),
            'email' => strtolower(trim($row['email'])),
            'created_at' => $now,
            'updated_at' => $now,
        ];
    }

    if (! empty($payload)) {
        DB::table('customers')->upsert(
            $payload,
            ['email'],
            ['name', 'updated_at']
        );
    }

    $import->incrementEach([
        'processed_rows' => count($rows),
        'success_rows' => count($payload),
        'failed_rows' => $failed,
    ]);
}

Mengapa upsert sering lebih aman? Karena import besar sering diulang. Jika memakai insert biasa tanpa perlindungan, Anda berisiko membuat data ganda. Dengan upsert berdasarkan kolom unik seperti email, data yang sudah ada akan diperbarui alih-alih diduplikasi.

Kapan memakai insert, insertOrIgnore, atau upsert?

  • insert: saat Anda yakin data baru semua dan ingin gagal jika ada duplikasi.
  • insertOrIgnore: saat duplikasi boleh diabaikan, tetapi Anda tidak butuh update data lama.
  • upsert: saat file import bisa berisi data baru dan data lama yang perlu diperbarui.

Syarat utamanya: pastikan ada unique index yang sesuai di database. Jangan mengandalkan pengecekan duplikasi hanya di level aplikasi.

Transaksi: gunakan seperlunya, jangan terlalu luas

Transaksi penting untuk menjaga konsistensi, tetapi transaksi besar untuk seluruh file sering kontraproduktif. Lebih aman menggunakan transaksi per chunk, terutama bila satu chunk memengaruhi beberapa tabel yang harus konsisten.

DB::transaction(function () use ($payload) {
    DB::table('customers')->upsert(
        $payload,
        ['email'],
        ['name', 'updated_at']
    );
});

Keuntungan transaksi per chunk:

  • Jika satu chunk gagal, rollback hanya terjadi pada chunk tersebut.
  • Lock database lebih singkat.
  • Penggunaan resource lebih terkendali.

Namun, jangan membungkus operasi yang tidak perlu dalam transaksi. Misalnya logging progress ke tabel import tidak selalu harus ikut transaksi data utama, tergantung kebutuhan konsistensi Anda.

Menghindari duplicate import

Duplikasi pada import besar bisa terjadi dalam beberapa bentuk:

  • User upload file yang sama dua kali.
  • Job diproses ulang karena retry setelah timeout atau exception.
  • Data file memang punya baris ganda.

Untuk mengatasinya, gabungkan beberapa lapis perlindungan:

1. Simpan hash file

Hitung hash file saat upload. Jika hash sama dan import sebelumnya masih processing atau sudah completed, Anda bisa menolak atau meminta konfirmasi.

2. Gunakan unique index

Misalnya pada tabel customers, buat unique index pada email. Ini adalah pertahanan paling kuat terhadap duplikasi data nyata.

3. Desain job yang idempoten

Job queue sebaiknya aman jika dijalankan ulang. Itulah salah satu alasan upsert lebih menarik dibanding insert biasa untuk banyak kasus import.

4. Simpan status import dengan jelas

Jangan hanya mengandalkan log aplikasi. Record status per import memudahkan Anda mencegah job yang sama diproses dua kali.

Bottleneck umum dan cara debug

Memory tetap tinggi meski sudah pakai chunk

Periksa apakah Anda masih mengumpulkan terlalu banyak data tambahan di memori, misalnya menyimpan semua row gagal dalam array besar. Simpan error ke tabel atau file log secara bertahap, bukan di memori sampai akhir.

Database masih lambat

Cek apakah ada query per baris untuk validasi relasi. Jika Anda perlu memetakan kode cabang atau kategori, ambil data referensi sekali per chunk, lalu buat lookup array di memori.

Job sering timeout

Kurangi ukuran chunk dan ukur durasi per chunk. Timeout worker harus disesuaikan dengan realitas kerja, tetapi sebaiknya yang dipercepat adalah prosesnya, bukan hanya timeout-nya yang diperbesar.

Import gagal di baris tertentu

Simpan nomor baris dan pesan error seperlunya. Untuk file besar, laporan error parsial jauh lebih berguna daripada hanya status “failed”.

Worker queue terlihat jalan, tetapi job menumpuk

Periksa koneksi queue, jumlah worker, dan apakah queue name sudah benar. Banyak kasus ternyata job dikirim ke queue imports tetapi worker hanya memproses queue default.

Pola implementasi yang biasanya paling masuk akal

Untuk sebagian besar aplikasi backend Laravel, pola berikut cukup aman dan mudah dirawat:

  1. Upload file dan simpan ke storage.
  2. Buat record import untuk tracking.
  3. Dispatch job ke queue khusus import.
  4. Baca file per chunk 500-1000 baris.
  5. Lakukan validasi ringan dan normalisasi data di level aplikasi.
  6. Gunakan upsert atau insertOrIgnore dengan unique index.
  7. Pakai transaksi per chunk jika satu chunk menyentuh beberapa tabel penting.
  8. Update progress agar bisa dimonitor user atau admin.

Jika kebutuhan Anda lebih kompleks, misalnya ada validasi lintas tabel yang berat, Anda bisa memecah lagi menjadi dua tahap: tahap parsing file ke tabel staging, lalu tahap sinkronisasi dari staging ke tabel utama. Ini membuat debugging dan audit jauh lebih mudah, meski implementasinya lebih panjang.

Penutup

Masalah timeout dan memory habis saat import Excel/CSV besar di Laravel hampir selalu berasal dari desain proses yang terlalu monolitik: semua dikerjakan dalam satu request, semua data dibaca sekaligus, dan semua row disimpan satu per satu. Solusi praktisnya adalah memecah alur kerja: queue untuk pekerjaan latar belakang, chunk reading untuk menahan penggunaan memori, batch insert atau upsert untuk mengurangi query, dan transaksi per chunk bila memang dibutuhkan.

Poin terpentingnya bukan sekadar “pakai queue”, tetapi memastikan seluruh proses import bersifat scalable dan idempotent. Dengan begitu, file besar tidak lagi menjadi sumber timeout yang sulit dilacak, dan proses import Anda lebih mudah dioperasikan di production.