SupportWriter space ↗
← Back to the journal
Laravel

Mengimpor CSV Besar di Laravel dengan Streaming dan Upsert

Proses katalog tanpa memuat seluruh berkas, validasi setiap baris, dan lakukan upsert bertahap dengan identitas SKU.

DAdministrator · 13 Oct 2026 · 4 min read

This article is not yet available in your selected language. Showing the original.

File katalog berisi ratusan ribu produk. Pendekatan file_get_contents() lalu memecah seluruh baris membuat pemakaian memori mengikuti ukuran file. Memanggil updateOrCreate() per produk juga menghasilkan banyak perjalanan ke database.

Studi kasus Laravel 13 ini memakai stream CSV dan upsert per 500 baris. Memori batch menjadi terbatas dan jumlah query turun. Contoh mengasumsikan tabel products dengan sku unik, name, price_cents integer, dan timestamps. Identitas SKU harus benar-benar memiliki unique index; argumen uniqueBy tidak membuat constraint database.

Persiapan proses impor

Simpan upload di disk privat, validasi ukuran dan jenis berkas, lalu jalankan impor melalui command atau job. Jangan menerima path filesystem bebas dari pengguna. Path harus berasal dari berkas yang disimpan aplikasi. Contoh berikut adalah inti proses backend, bukan endpoint upload lengkap.

Header yang diterima tepat sku,name,price_cents. Nominal berupa integer unit terkecil agar tidak terkena pembulatan floating point. Tentukan apakah SKU sensitif huruf besar; pada MySQL, collation index dapat membuat abc dan ABC dianggap sama. Normalisasikan data dan pilih collation sesuai kontrak katalog.

use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;

$stream = Storage::disk('local')->readStream($storedPath);
if (! is_resource($stream)) {
    throw new RuntimeException('Berkas impor tidak dapat dibaca.');
}

$batch = [];
$lineNumber = 1;

try {
    $header = fgetcsv($stream, null, ',', '"', '');
    if ($header !== ['sku', 'name', 'price_cents']) {
        throw new RuntimeException('Header CSV tidak sesuai.');
    }

    while (($row = fgetcsv($stream, null, ',', '"', '')) !== false) {
        $lineNumber++;
        if ($row === [null]) {
            continue;
        }
        if (count($row) !== 3) {
            throw new RuntimeException("Jumlah kolom salah pada record {$lineNumber}.");
        }

        $data = Validator::make([
            'sku' => strtoupper(trim($row[0])),
            'name' => trim($row[1]),
            'price_cents' => trim($row[2]),
        ], [
            'sku' => ['required', 'string', 'max:64', 'regex:/^[A-Z0-9_-]+$/'],
            'name' => ['required', 'string', 'max:255'],
            'price_cents' => ['required', 'integer', 'min:0', 'max:2000000000'],
        ])->validate();

        $batch[$data['sku']] = [
            'sku' => $data['sku'],
            'name' => $data['name'],
            'price_cents' => (int) $data['price_cents'],
            'created_at' => now(),
            'updated_at' => now(),
        ];

        if (count($batch) >= 500) {
            DB::table('products')->upsert(
                array_values($batch), ['sku'], ['name', 'price_cents', 'updated_at'],
            );
            $batch = [];
        }
    }

    if ($batch !== []) {
        DB::table('products')->upsert(
            array_values($batch), ['sku'], ['name', 'price_cents', 'updated_at'],
        );
    }
} finally {
    fclose($stream);
}

Argumen escape kosong pada fgetcsv dipasang secara eksplisit. Field yang berisi koma atau newline tetap diproses parser CSV, bukan explode(). Nomor contoh menghitung record logis; field multiline berarti nomor ini tidak selalu sama dengan nomor baris fisik editor teks.

Mengapa batch memakai SKU sebagai key?

Jika satu batch memiliki SKU yang sama beberapa kali, baris terakhir menggantikan baris sebelumnya. Kebijakan ini sengaja dipilih dan harus dijelaskan kepada pengunggah. Duplikat pada batch berbeda juga memperbarui nilai sebelumnya melalui upsert. Jangan melaporkan jumlah baris terdampak SQL sebagai jumlah produk baru karena arti affected rows bergantung mesin database.

Daftar kolom update tidak menyertakan created_at, sehingga tanggal pembuatan produk lama tetap terjaga. Query Builder juga melewati observer, mutator, dan event model; normalisasi yang biasanya dilakukan model harus diterapkan secara eksplisit.

Impor parsial atau semua berhasil?

Contoh berhenti pada record invalid dan mempertahankan batch yang sudah tersimpan. Menjalankan ulang file aman dari duplikasi SKU, tetapi bukan transaksi seluruh file. Tandai status impor sebagai gagal beserta record yang perlu diperbaiki, dan jelaskan bahwa sebagian data mungkin sudah berubah.

Jika pengguna membutuhkan hasil atomik, streaming ke tabel staging yang terikat import_id, validasi seluruh berkas, lalu promosikan data sesuai strategi transaksi aplikasi. Jangan menahan satu transaksi raksasa selama upload dan parsing. Untuk input tidak tepercaya, batasi panjang record atau field selain ukuran file karena satu record sangat besar tetap bisa menghabiskan memori.

Uji koma dalam nama, field multiline, header salah, nilai negatif, SKU duplikat, berkas kosong, dan kegagalan pada batch kedua. Pastikan menjalankan ulang impor tidak menambah jumlah produk untuk SKU yang sama.

Rujukan API: Upserts, File storage, dan Validation.

Comments 0

Share your questions, experiences, or feedback about this article.

No comments yet. Be the first to share your feedback.

← Explore more notes