NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Imports for Laravel

Reading files

Both readers are part of the package and stream: only the current row is in memory, and XLSX shared strings are spooled to a php://temp stream (memory up to xlsx.shared_strings_memory, then disk) — a workbook with 200,000 strings reads in well under 16 MB. There is no third-party spreadsheet library to install or keep updated.

Format by content

The extension and the client MIME type are ignored. An empty file is rejected (empty), and one larger than limits.file_size is rejected before reading (too_large):

ContentResult
a ZIP with a SpreadsheetML workbookXLSX (.xlsm only with xlsx.allow_macro_enabled)
OpenDocument, binary Excel, another ZIPopen_document · xlsb · unsupported_format
an OLE compound file (.xls, password-protected workbooks)legacy_or_encrypted
UTF-16 / UTF-32 byte order marks, a NUL byte in the first 64 KButf16 · utf32 · binary
anything elseCSV

CSV

  • UTF-8 with or without a BOM. The whole file is checked in 64 KB blocks; the first invalid byte rejects it as not_utf8 with its line and a guess (Windows-1250, Windows-1252, ISO-8859) and tells the user to save as “CSV UTF-8”. Pure ASCII passes.
  • Excel’s sep=; first line sets the delimiter. Otherwise it is sniffed among csv.delimiters (comma and semicolon) from the most consistent parse, or set with upload(…, delimiter: ';') or useDelimiter().
  • RFC 4180 quoting (delimiters, quotes and newlines inside quotes), no escape character; LF, CRLF and CR-only files.
  • Row numbers are the ones a spreadsheet would show; blank records count but are not imported; missing trailing cells are empty; a non-empty value beyond the header’s last column is a row error.
  • A leading ' before = + - @, a tab or a CR is stripped (csv.unescape_formula_prefix) — what the package’s own CSV writer adds.

XLSX

AreaSupported
PartsLocated through the package relationships; transitional and strict OOXML; absolute and case-different part names.
SheetsThe first visible worksheet by default; sheet: 'Name' (exact, then case-insensitive — hidden sheets too) or sheet: 2 (1-based among visible sheets); chart sheets are skipped.
CellsShared strings (rich text concatenated, phonetic runs skipped, _xHHHH_ decoded), inline strings, formula results (cached values — formulas are never evaluated), booleans, errors (a cell error at validation), ISO dates, numbers.
DatesNumber formats decide; the 1900 system (Lotus’ phantom 29 Feb 1900 refused) and the 1904 system; time-only formats read as H:i:s; wall-clock time in the session timezone — a DateTime field then converts it to app.timezone.
Sparse rowsMissing r attributes read sequentially; formatted blank rows are skipped; data below more than xlsx.max_blank_rows empty rows rejects the file instead of being dropped.

Container and XML safety: the ZIP is opened read-only with a local-vs-central header consistency check; the entry count is capped (xlsx.max_entries); encrypted entries are refused; no part may declare more than xlsx.max_part_size; the bytes really inflated are counted while parsing, per part and across the read (xlsx.max_inflated), so a size that lies is stopped mid-stream; entries are parsed straight from the ZIP, never extracted; a DOCTYPE in any parsed part rejects the file before an entity could be declared, and an external entity is never resolved. The reader restores the process’s previous libxml error mode when the file closes, so queue workers and Octane keep their own.

Sheets, delimiters and title rows

// A title row above the headers, a named sheet, a forced delimiter
$session = Imports::for($team)->by($user)->upload(
    'assets',
    $request->file('file'),
    sheet: 'Assets',        // a name (exact, then case-insensitive) or a 1-based position among visible sheets
    delimiter: ';',
    headerRow: 2,
    locale: 'sk',           // labels, messages and the decimal separator of this session
);

// Change it later — the preview is dropped (preview again)
$session->useSheet('Assets');   // XLSX: another sheet
$session->useDelimiter(';');    // CSV: override the sniffed delimiter
$session->useHeaderRow(2);      // a title row above the headers

Limits

limits.file_size (KB, default 10,240), limits.rows (data rows, default 10,000, ceiling 100,000), limits.columns (default 100 — non-empty cells only) and limits.cell_length. An importer may override each with limits() — withCellLength() for the cell length.

Rejections

A whole upload is rejected and nothing is stored. ImportFileRejectedException (HTTP 422) carries reason (a RejectionReason) and context; its message is translated in English and Slovak and says what to do:

use RoundlyConsulting\Imports\Exceptions\ImportFileRejectedException;

try {
    $session = Imports::for($team)->by($user)->upload('assets', $request->file('file'));
} catch (ImportFileRejectedException $e) {
    $e->reason;          // RejectionReason::NotUtf8
    $e->context;         // the values in the message (line, encoding guess)
    $e->getMessage();    // translated, and says how to fix the file
}
RejectionReasonWhen
emptyThe file has no bytes.
too_largeOver limits.file_size — checked before reading.
macro_enabledAn .xlsm workbook while xlsx.allow_macro_enabled is off.
open_document · xlsb · unsupported_formatAn .ods, a binary workbook, another ZIP.
legacy_or_encryptedAn OLE file: .xls or a password-protected workbook.
utf16 · utf32 · binary · not_utf8The encoding checks; not_utf8 names the line and a guess (Windows-1250, Windows-1252, ISO-8859) and asks for “CSV UTF-8”.
no_headerThe header row is empty.
too_many_columns · too_many_rowsOver the importer’s limits.
malformed_spreadsheetA damaged or invalid workbook, an inconsistent ZIP, a DOCTYPE in a parsed part.
data_after_blank_rowsXLSX: a row with data below more than xlsx.max_blank_rows empty rows — rows are never dropped silently.
zip_too_largeA workbook part inflating past xlsx.max_part_size / xlsx.max_inflated (also file batches).
format_not_acceptedThe importer’s formats() excludes the detected format.
too_many_files · archive_too_large · zip_ratio · zip_unsafe_path · zip_symlink · zip_encrypted · zip_inconsistent · zip_entry_too_largeFile batches — see File batches.
unreadableThe upload could not be read.

A sheet that does not exist throws SheetNotFoundException (422) — available lists the sheet names.

The standalone reader

The pipeline’s own reader, usable on its own for any CSV or XLSX your app needs to read — no session, nothing stored, the same safety checks:

use RoundlyConsulting\Imports\DataTransferObjects\ImportSource;
use RoundlyConsulting\Imports\Facades\Imports;

$file = Imports::read($request->file('file'), sheet: 'Assets', delimiter: null, headerRow: 1);
// or Imports::read('/path/to/file.csv'), Imports::read(ImportSource::disk('s3', 'exports/assets.xlsx'))

try {
    $file->format();            // SourceFormat::Csv | SourceFormat::Xlsx
    $file->sheets();            // list<SheetInfo> (XLSX), [] for CSV
    $file->sheet();             // the sheet read (XLSX) or null
    $file->delimiter();         // CSV: the delimiter used; delimiterSource(): Sniffed | Hint | Override
    $file->encoding();          // CSV: `utf-8` or `utf-8-bom`; null for XLSX
    $file->headerRow();         // 1-based
    $file->headers();           // list<string|null>, null for a blank header cell
    $file->displayHeaders();    // list<string>, a blank header is `Column C`
    foreach ($file->rows() as $rowNumber => $cells) {   // keyed by the row number a user sees
        // list<Cell|null> — null for an empty cell
    }
    $file->count();             // data rows, in one full pass (every limit enforced)
} finally {
    $file->close();             // also closed on destruct
}

rows() rejects the file (too_many_rows) at the first row over the limit. A disk source is copied to a temp directory imports:sweep clears. SpreadsheetFile::open() reads a local path directly with ReadOptions:

use RoundlyConsulting\Imports\Reading\{Cell, ReadOptions, SpreadsheetFile};

$file = SpreadsheetFile::open('/path/to/file.xlsx', ReadOptions::fromConfig(sheet: 'Assets', headerRow: 2));

foreach ($file->rows() as $rowNumber => $cells) {
    $cell = $cells[0];      // ?Cell
    $cell?->type;           // CellType: text, number, boolean, date, error
    $cell?->value;          // string, numeric string, bool, CarbonImmutable or an error code (#N/A)
    $cell?->display();
}

// Accepted everywhere a file is
ImportSource::upload($uploadedFile); ImportSource::path('/tmp/a.csv'); ImportSource::disk('s3', 'exports/a.xlsx');

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.