How to import CSV files in Laravel (Blade + Inertia + Vite)

Add a complete CSV and Excel import widget to your Laravel app — Blade, Inertia, or Livewire — with AI column mapping, in-browser validation, and an Eloquent-friendly bulk insert handler.

Laravel apps reach for Maatwebsite/Excel the moment they need CSV import — and it’s a great library for what it does. But it solves the wrong problem when you’re shipping a customer-facing import flow. Your users don’t want to upload a file and wait 30 seconds for a server-side queue job. They want to drop in a spreadsheet, see their data in a preview, fix the rows that don’t validate, and click Confirm.

This guide shows how to wire Rowslint — a client-side, drop-in CSV and Excel importer — into your Laravel app. AI column mapping, in-browser validation, multi-format support, and a clean Eloquent insert path. Setup takes five minutes.

What is Rowslint

Rowslint is an embedded CSV and Excel importer for web apps. From a Blade template, Inertia component, or Livewire view, you call launchRowslint(), your users get a polished import experience, and you receive validated, typed rows ready to insert into your Laravel database.

The importer parses files entirely in the browser. Your Laravel server never receives a file — only a JSON payload of clean rows.

Step 1: Create a Rowslint account and template

Sign up for the free tier. In the dashboard:

  1. Create a template (e.g. customers_v1).
  2. Add columns matching your customers table.
  3. Set types and validators.
  4. Save and copy the template key + your organization API key.

Template setup guide →

Step 2: Install the package and configure Vite

npm install @rowslint/importer-js

Add the API key to .env:

VITE_ROWSLINT_API_KEY=org_pk_live_xxxxxxxxxxxxx

Make sure vite.config.js exposes the VITE_* env variables to the client (Laravel’s default Vite setup already does).

Step 3: Add the importer to a Blade template

The simplest path — no Inertia, no Livewire, just Blade + Vite:

{{-- resources/views/customers/index.blade.php --}}
@extends('layouts.app')

@section('content')
  <div class="header">
    <h1>Customers</h1>
    <button id="import-customers-btn" class="btn btn-primary">
      Import customers
    </button>
  </div>

  <table>...</table>
@endsection

@push('scripts')
  @vite(['resources/js/customers-import.ts'])
@endpush
// resources/js/customers-import.ts
import { launchRowslint } from '@rowslint/importer-js';

const button = document.getElementById('import-customers-btn');
button?.addEventListener('click', () => {
  launchRowslint({
    apiKey: import.meta.env.VITE_ROWSLINT_API_KEY,
    config: { templateKey: 'customers_v1' },
    onImport: async (result) => {
      if (result.status !== 'success') return;
      const csrf = document.querySelector('meta[name="csrf-token"]')!.getAttribute('content')!;
      await fetch('/api/customers/bulk', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'X-CSRF-TOKEN': csrf,
        },
        body: JSON.stringify({ rows: result.data }),
      });
      window.location.reload();
    },
  });
});

Make sure your layout includes <meta name="csrf-token" content="{{ csrf_token() }}"> so the fetch request passes Laravel’s CSRF check.

Step 4: Receive rows in a Laravel controller

Define a route in routes/web.php (or routes/api.php if you prefer):

use App\Http\Controllers\CustomerImportController;

Route::post('/api/customers/bulk', [CustomerImportController::class, 'store'])
    ->middleware(['auth', 'throttle:5,1']);

Create the controller:

<?php

namespace App\Http\Controllers;

use App\Models\Customer;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;

class CustomerImportController extends Controller
{
    public function store(Request $request)
    {
        $data = $request->validate([
            'rows' => ['required', 'array', 'max:50000'],
            'rows.*.email' => ['required', 'email'],
            'rows.*.name' => ['required', 'string', 'max:255'],
            'rows.*.plan' => ['required', Rule::in(['free', 'pro', 'enterprise'])],
            'rows.*.created_at' => ['required', 'date'],
        ]);

        $inserted = 0;
        DB::transaction(function () use ($data, &$inserted) {
            foreach (array_chunk($data['rows'], 500) as $batch) {
                $now = now();
                $records = array_map(fn ($r) => [
                    ...$r,
                    'created_at' => $r['created_at'],
                    'updated_at' => $now,
                ], $batch);

                Customer::insertOrIgnore($records);
                $inserted += count($batch);
            }
        });

        return response()->json(['inserted' => $inserted]);
    }
}

Three things this controller gets right:

  1. Re-validates every row even though Rowslint validated client-side. Never trust a client.
  2. Batches in chunks of 500 to stay under MySQL’s max_allowed_packet and Postgres parameter limits.
  3. Wraps in a transaction so a partial failure rolls back cleanly.

Step 5: Inertia + Vue/React variant

For Inertia apps, use Inertia.post() instead of raw fetch:

<script setup lang="ts">
import { router } from '@inertiajs/vue3';
import { launchRowslint } from '@rowslint/importer-js';

const handleImport = () => {
  launchRowslint({
    apiKey: import.meta.env.VITE_ROWSLINT_API_KEY,
    config: { templateKey: 'customers_v1' },
    onImport: (result) => {
      if (result.status === 'success') {
        router.post('/api/customers/bulk', { rows: result.data });
      }
    },
  });
};
</script>

<template>
  <button @click="handleImport">Import customers</button>
</template>

Inertia automatically handles CSRF, redirects, and reload on success — no extra wiring needed.

Step 6: Livewire variant

For Livewire 3 apps, dispatch a browser event from JS and listen for it server-side:

// app/Livewire/CustomerImport.php
class CustomerImport extends Component
{
    public function importRows(array $rows)
    {
        foreach (array_chunk($rows, 500) as $batch) {
            Customer::insertOrIgnore($batch);
        }
        $this->dispatch('imported', count($rows));
    }

    public function render()
    {
        return view('livewire.customer-import');
    }
}
<div>
  <button id="import-btn">Import customers</button>

  <script type="module">
    import { launchRowslint } from '@rowslint/importer-js';

    document.getElementById('import-btn').addEventListener('click', () => {
      launchRowslint({
        apiKey: '{{ env('VITE_ROWSLINT_API_KEY') }}',
        config: { templateKey: 'customers_v1' },
        onImport: (result) => {
          if (result.status === 'success') {
            @this.call('importRows', result.data);
          }
        },
      });
    });
  </script>
</div>

Step 7: Add async validation against Laravel

Configure async validators in your Rowslint template (dashboard) pointing at a Laravel route:

Route::post('/api/validate/email', function (Request $request) {
    $email = $request->input('value');
    $exists = Customer::where('email', $email)->exists();

    return response()->json([
        'valid' => !$exists,
        'message' => $exists ? 'A customer with this email already exists' : null,
    ]);
})->middleware('auth');

Users see inline errors as they map columns. No re-uploads.

Why Laravel teams pick Rowslint over Maatwebsite/Excel for UIs

Maatwebsite/Excel is excellent for server-side processing — generating reports, queue-based imports, CLI artisan commands. It’s the wrong tool for a customer-facing UI:

  • Upload-then-wait is a bad UX. Your user uploads a 30 MB CSV, waits for the queue worker, gets an email with errors, fixes the file, re-uploads. Rowslint is interactive — they see errors as they map and fix in place.
  • Server-side parsing eats CPU. A 100K-row Excel file pegs a worker for ~30 seconds. Rowslint’s parsing happens on the user’s MacBook.
  • No AI column matching. If your users’ columns don’t match your schema names exactly, Maatwebsite throws or silently drops them. Rowslint matches "Email Addr"email automatically.

Use both. Maatwebsite/Excel for back-office and queue jobs; Rowslint for everything user-facing.

Production-ready checklist

  • CSRF token in the layout and forwarded with fetch
  • auth and throttle middleware on the bulk endpoint
  • Form Request validation for every row
  • DB::transaction wrapping the inserts
  • insertOrIgnore or upsert() for idempotency
  • Chunks of 500–1000 rows to stay under DB packet limits
  • Async validators wired for uniqueness checks

Conclusion

Replacing a server-side Excel upload flow with a client-side Rowslint integration is one of the highest-impact UX upgrades you can ship in a Laravel app. Your users get instant validation; your servers stop chewing through 30 MB files; your support tickets drop.

Start with the free tier and ship a modern import flow in your Laravel app today. See the JavaScript SDK reference for the full API.

Frequently asked questions

What is the best way to import CSV files in Laravel?
For customer-facing import flows, the most maintainable approach is a client-side importer like Rowslint that hands cleaned, validated rows to a Laravel controller. This avoids file uploads, server-side parsing, and the complexity of Maatwebsite/Excel for what is effectively a UI problem. For internal CLI imports, `Maatwebsite/Excel` and Laravel's chunked CSV reader are still good options.
How do I integrate Rowslint with Laravel Blade?
Install `@rowslint/importer-js` via npm in your Laravel project, compile assets with Vite, and import the `launchRowslint` function in your Blade template's script section. The importer runs entirely in the browser and POSTs validated rows to any Laravel route you point it at.
Does Rowslint work with Laravel Inertia and Livewire?
Yes. With Inertia, call `launchRowslint()` from your Vue or React component and use `Inertia.post()` to submit the rows to a Laravel controller. With Livewire, dispatch a Livewire event from `onImport` to trigger the server-side handler. Both patterns are documented in the article.
How do I bulk insert imported rows in Laravel?
Use `Model::insert($chunkedRows)` for raw inserts that bypass Eloquent events, or `chunk()` + `create()` if you need observers/events. Always batch in chunks of 500–1000 rows to avoid `max_allowed_packet` errors and connection timeouts on large imports.
Can I validate imported rows against my Laravel database?
Yes. Configure async validators in your Rowslint template that hit a Laravel route returning a JSON `{ valid, message }` response. Users see inline errors during column mapping for uniqueness checks, foreign keys, and custom Laravel validation rules.
Should I use Rowslint or Maatwebsite/Excel?
Different jobs. Rowslint is for customer-facing import UIs where end-users upload spreadsheets to your Laravel app. Maatwebsite/Excel is for server-side CLI/queue work — generating reports, processing internal files, exporting data. Many Laravel apps use both.
ship it this sprint

Your users have a spreadsheet.
Let's give them somewhere to put it.

14-day free trial · No credit card required · Cancel anytime