askdkc/livewire-csv
Livewire CSV is a Laravel Livewire package for handling CSV imports with a simple API, using database UPSERT to create or update records. It includes a setup command, importer and button components, queue support, and TALL stack integration.
1. Install and scaffold
composer require askdkc/livewire-csv
php artisan livecsv-setup
livecsv-setup publishes the config file and migrations, then interactively asks whether it should run php artisan migrate for you (and, per the README, asks you to star the repo — answer freely). If you'd rather stay in control:
php artisan vendor:publish --tag="livewire-csv-migrations"
php artisan vendor:publish --tag="livewire-csv-config"
php artisan migrate
2. Check the queue tables exist — imports run through queued jobs, so you need the jobs/batches tables:
php artisan queue:table
php artisan queue:batches-table
php artisan migrate
3. Add the trait to your User model (yes, User specifically — the trait is a gate for the importer, not a per-model concern):
use Askdkc\LivewireCsv\Concerns\HasCsvImports;
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable, HasCsvImports;
}
4. Drop the component into a Blade view — this is the whole "UI" of the package:
<livewire:csv-importer
:model="App\Models\Contact::class"
:columns-to-map="['id', 'name', 'email']"
:required-columns="['name', 'email']"
:column-labels="['id' => 'ID', 'name' => 'Name', 'email' => 'Email Address']"
:upsert-columns="['email']"
/>
<x-csv-button class="rounded py-2 px-3 bg-indigo-500 text-white">Import</x-csv-button>
Where to look first: the published config/livewire-csv.php (it's four keys: layout, file_type, set_delimiter, file_upload_size) and the migrations the setup command drops in. Then read the component source in vendor/askdkc/livewire-csv/src — the README is decent but the props table is the entire public API surface, so the class will answer "what actually happens on upload."
First use case: an admin-only "Import contacts" page behind your existing auth middleware, mapping name/email from a spreadsheet, upserting on email. Build that before you touch queues or TSV.
Wrap the component in your own page, not your layout. The importer is a full Livewire component with its own upload/mapping/confirm steps. Give it a dedicated route inside your admin group:
// routes/web.php
Route::middleware(['auth', 'can:import-data'])->group(function () {
Route::get('/imports/contacts', ImportContacts::class)->name('imports.contacts');
});
{{-- resources/views/livewire/import-contacts.blade.php --}}
<x-app-layout>
<x-slot name="header">{{ __('Import Contacts') }}</x-slot>
<div class="max-w-3xl mx-auto py-8">
<p class="text-sm text-gray-600 mb-4">
{{ __('CSV must include a header row. Existing records are matched on email.') }}
</p>
<livewire:csv-importer
:model="App\Models\Contact::class"
:columns-to-map="['name', 'email', 'company']"
:required-columns="['name', 'email']"
:column-labels="['name' => 'Full name', 'email' => 'Email', 'company' => 'Company']"
:upsert-columns="['email']"
/>
</div>
</x-app-layout>
Make the upsert key a real unique index. This is the single most important design decision. :upsert-columns compiles to a database UPSERT, so the columns you name must carry a primary or unique index — otherwise you get duplicates, or an outright failure:
// in your model's migration
$table->string('email')->unique();
// then in the component: :upsert-columns="['email']"
If you want a non-id primary key, remove $table->id() (you can't have two primary keys) and declare e.g. $table->string('email')->primary();.
Default behavior = upsert on id. Omitting :upsert-columns means the importer looks for an id column in the CSV. For "append new rows" imports that's usually wrong — either supply :upsert-columns with your natural key, or make sure the exported CSV includes existing IDs.
Use the button component as a trigger, not the importer itself. x-csv-button is an Alpine-driven wrapper; treat it as your "open the import panel" affordance. Style it purely with class (Tailwind utilities), since layout only supports tailwindcss today:
<x-csv-button class="rounded-md px-4 py-2 bg-indigo-600 text-white text-sm" type="button">
{{ __('Import CSV') }}
</x-csv-button>
Non-TALL projects: publish views and compile, or use the directives. Put @csvStyles in <head> and @csvScripts before </body> — the package ships its own assets and will not render correctly without one of these two paths.
<head>@csvStyles</head>
...
@csvScripts
Large files → keep the queue worker running. Imports run as background jobs using PHP generators, so memory stays flat but results are asynchronous. In production run php artisan queue:work (or Horizon) and set QUEUE_CONNECTION as you normally would; imports are also batched, which is why the batches table matters. Locally, queue:listen on a second terminal while you test.
Tune the config for the file format you actually receive:
return [
'layout' => 'tailwindcss',
'file_type' => 'csv', // 'tsv' for tab-separated
'set_delimiter' => ',', // ';' for European Excel exports
'file_upload_size'=> 102400, // KB, i.e. 100MB ceiling
];
European Excel exports (semicolon-separated, sometimes BOM-prefixed) are the most common real-world failure — set set_delimiter before you blame the package.
Testing: the package ships helpers and its own suite (composer test). In your app, assert on the end state of the database rather than on the component's internals — upload a fixture CSV, run the queue, and assert counts/values. Because the write path is upsert, the useful assertions are:
Queue::fake(); // or Bus::fake()
// act: upload fixture
$this->assertDatabaseHas('contacts', ['email' => '[email protected]', 'name' => 'A']);
$this->assertDatabaseCount('contacts', 2); // one updated, one created
Integration tip — keep a narrow "import contract" per model. Rather than one generic importer for every table, define one Livewire page per entity with hardcoded columns-to-map/required-columns. You get sane validation messages, a stable label set for users, and no chance of a fat-fingered prop hitting the wrong model class.
UPSERT bypasses Eloquent entirely. This is the biggest trap. The write goes through the query builder, so model events (creating, saving), observers, accessors/mutators, and casts do not fire. Consequences to plan for:
created_at/updated_at may need to appear in the CSV or be handled by defaults — don't assume $table->timestamps() gets populated.HasCsvImports on User is a gate, not magic. The trait on User is required by the package regardless of which model you import into. If you import as a guest, or your auth guard differs, expect the component to misbehave. And because the component is just a Livewire component, it is mountable by anyone who can reach the route — always place it behind auth/authorization. The package provides no built-in ACL.
Unique index = upsert index. If you pass :upsert-columns="['name', 'email']", your migration must declare $table->unique(['name', 'email']);. Mismatches produce duplicate rows silently in some drivers. Verify with SHOW INDEX before debugging anything else.
Column mapping is positional-by-header, so headers must match exactly. :columns-to-map must be the DB column names, and the CSV header row must be the thing the mapping UI presents. Extra columns in the file are ignored; missing required ones fail validation using :required-columns and display via :column-labels.
Two different publish tags for views. The README uses --tag="livewire-csv-views" in the manual-config section and --tag="csv-views" in the TALL section. Try both; if neither produces files, run php artisan vendor:publish without a tag and pick the numeric entry from the list. Same trick applies if livecsv-setup partially fails.
File size is validated twice. file_upload_size in the package config and Livewire's own temporary_file_upload.rules / max_upload_time (plus upload_max_filesize/post_max_size in php.ini). A failing large upload often shows no package error at all — check storage/logs/laravel.log and Livewire's config first.
"It hangs after upload." Nine times out of ten the queue worker isn't running, or job_batches was never migrated. The import is asynchronous by design; the confirm step only dispatches the batch. Check jobs, failed_jobs, and job_batches rows, then php artisan queue:work --queue=default.
Views are your extension point, not PHP. Since there's essentially no documented hook system, customization means publishing the views and editing them: replace the Tailwind markup, add explanatory copy, add a "download template CSV" link, or swap in a Bootstrap layout. Combine with layout in config being effectively hardcoded to tailwindcss — non-Tailwind projects should expect to own the published view files permanently.
Small package, so vendor the risk. 17 stars, 0 dependents, one maintained author. That's fine for an admin bulk-import tool; it's not fine as load-bearing infrastructure. Practical mitigations:
"askdkc/livewire-csv": "^2.0"), and read the changelog before upgrading — v2.0.0 is largely dependency bumps plus Laravel 12/13 + Livewire 4 support, so major jumps are where breakage lives.upsert(). If it's abandoned, reimplementing the mapping UI is a day of work, not a rewrite.Data safety checklist before the first production import: take a DB snapshot, import into staging with a production-shaped file, confirm row counts, and dry-run your "unique index" assumption with a deliberately duplicated CSV (which is what you're testing — that updates happen instead of inserts). Because upsert is destructive-by-design, a wrong :upsert-columns can overwrite real records on first upload.
Encoding and delimiters. Excel on Windows produces UTF-8-with-BOM or Shift-JIS depending on the user; the package's set_delimiter/file_type config handles separation but not encoding. Add a "please save as UTF-8 CSV" note next to the component, or normalize the file with a small pre-processing step if you can't control the source.
How can I help you explore Laravel packages today?