Appearance
CSV and Excel import in Laravel: products, users and posts, mapped in two steps
A supplier sends the new price list as an Excel file every season. The old shop was on WooCommerce, and its product catalogue is a CSV with fifty-odd columns. The members of a club live in another system and have to become users of the site. A hundred posts need a new category, and the quickest way to do it is a spreadsheet. Every site gets files like these, and on a Laravel site the usual answer is a one-off script: it reads the file, maps the columns by hand, and when a row is wrong, it stops halfway with a stack trace, or worse, goes on and writes the wrong thing.
kitloom/spreadsheets is that script, written once, for every record on the site: posts of any type, users, and with the shop, products, orders and order lines. CSV, Excel (XLSX) and OpenDocument (ODS) go in and come out, from the admin, from the console, and from an AI agent.
Ask the agent
With kitloom/ai connected, an agent working on the site through MCP takes a file at an address and does the whole thing in words:
- "Load this price list into the products:
https://files.example.com/autumn.csv. Update the ones we already have." - "Here is the WooCommerce export of the old shop. Which rows would fail? Don't write anything yet."
- "Export last month's orders as an Excel file."
Three tools do it. sheet_preview fetches the file, matches its header row to the sheet's columns and does a dry run: how many rows would be created, updated, skipped or fail, and why, with nothing written. sheet_import then runs it, with the agent's own choice of columns where the guess was wrong, and the same switches a person has: update_existing, clear_empty, dry_run. sheet_export writes the file and answers with a download link that works for an hour. sheet_runs_list and sheet_run_get read the history and the logs, so the agent can tell you "row 14 failed: the column Stock takes a whole number" without you opening the admin.
The agent acts as the user whose key it holds, with that user's permissions: an author's key imports posts and nothing else, a key without the right to give the administrator role cannot give it from a file either. A row never deletes or trashes anything, and every run is in the history under the agent's name. How to connect one: Run your site from Claude or Cursor.
Importing in two steps
Spreadsheets → Import asks two things: what the rows are (a sheet: Posts, Pages, any other post type, Users, Products) and the file. A CSV may need its encoding, when Excel on Windows saved it in the letters of its own language, and its delimiter; left alone, the delimiter is read from the first line.
The file then waits in the History for its columns. Every column of the file is shown with its first rows and the column of the sheet it goes into, chosen for you: Regular price, regular_price and Regular Price? all land on Regular price, and the names WordPress and WooCommerce use (post_title, Short description, Attribute 1 value(s)) match too. A supplier's own Price column is matched by hand in one click; a column you do not want is left at skip. A column the sheet requires has to be chosen before the run can start.
Three switches decide what a row does to a record that is already there:
- Update existing records. A row is looked up by its ID, then by what else names it: the slug of a post, the SKU of a product, the email of a user. Off, such a row is skipped and the log says so. On, the record is changed, and only in the columns you mapped.
- Empty cells clear values. Off by default, so an empty cell changes nothing. On, an empty cell in a mapped column empties the value: the sale price of a sale that has ended, the tags of a product, an excerpt. What a record cannot be without (a product's type, an existing user's email) is refused, and the log names the column.
- Dry run. Everything is read and checked, nothing is written; the counts say what would happen. Preview does the same on the spot, without leaving the page.
The import runs on the queue, so a catalogue of thousands of rows does not keep a browser tab open, and its page in the History fills in as it goes.
A cell holds what the column takes: numbers with a point or a comma, yes/no or 1/0, dates in any common form, categories as Travel > Rivers with a child under its parent, a featured image as an address the site fetches into the media library, custom fields as field:isbn, other meta as meta:shelf. Users never carry a password in either direction: a new user gets an email inviting them to choose one. The easiest way to get the columns right is to export a few records first and use that file as the template.
When a row is wrong
A row that cannot go in, a price that is not a number, a status that does not exist, a role you may not give, fails on its own: nothing of it is written, the log says why, and the other rows go in. The History shows the run with 3 rows: 2 created, 0 updated, 0 skipped, 1 failed and, under it, Row 3: The column "Regular price" takes a number. Fix the cell and import the file again with Update existing records on: the rows that went in the first time are found and left as they are, the fixed one goes in. Nothing is imported twice.
Exporting
Spreadsheets → Export takes a sheet, its filters (a status, a date range, a category, a role, a product type) and a format, and the file downloads. The first row names the columns the way Import reads them back, so an exported file can be edited in Excel and imported again unchanged. Products come out in WooCommerce's own columns, so the file also goes into a WooCommerce shop as it is, and a WooCommerce export comes in the same way. Orders and their lines are export only: one row per order, or one per line with the SKU and the quantity, for the accountant or the warehouse.
Every export is written down in the History too, with its filters, and keeps its file for a day.
For a Laravel developer
A module adds a sheet with a Kitloom\Spreadsheets\Contracts\Sheet tagged Sheet::TAG: its key, label, permissions, columns, filters, and how it finds and writes a row through the site's writers, as the acting user. That is all the shop did for products, orders and order lines; a sheet with the key of a built-in one replaces it. The console has the same three steps: kitloom:sheets lists the sheets and their columns, kitloom:sheet-import and kitloom:sheet-export take a file, with --map, --update, --clear-empty, --dry-run and --as=user. Files are read and written with OpenSpout, row by row, so a large file does not need a large server.
Watch it
Three short lessons from the admin, narrated:
Try it
Open a sandbox in one click, with no sign-up: Filament or Nova. What every column takes, the commands and the agent tools are in the Spreadsheets guide. A shop that moves from WooCommerce brings its catalogue with it without a file at all: Migrate WooCommerce to Laravel.