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:
- Create a template (e.g.
customers_v1). - Add columns matching your
customerstable. - Set types and validators.
- Save and copy the template key + your organization API key.
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:
- Re-validates every row even though Rowslint validated client-side. Never trust a client.
- Batches in chunks of 500 to stay under MySQL’s
max_allowed_packetand Postgres parameter limits. - 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"→emailautomatically.
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
-
authandthrottlemiddleware on the bulk endpoint - Form Request validation for every row
-
DB::transactionwrapping the inserts -
insertOrIgnoreorupsert()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.