filipefernandes / laravel-turbo-excel
Blazing fast, memory-efficient Excel and CSV exports for Laravel powerd by OpenSpout.
Package info
github.com/filipefernandes9747/laravel-turbo-excel
pkg:composer/filipefernandes/laravel-turbo-excel
Requires
- php: ^8.4
- illuminate/bus: ^12.0
- illuminate/database: ^12.0
- illuminate/queue: ^12.0
- illuminate/support: ^12.0
- openspout/openspout: ^4.23
Requires (Dev)
- laravel/pint: ^1.13
- orchestra/testbench: ^10.0
- pestphp/pest: ^2.0|^3.0
- pestphp/pest-plugin-laravel: ^2.0|^3.0
README
Memory-efficient Excel and CSV exports and imports for Laravel, powered by openspout/openspout.
Export and import classes implement lightweight Concern interfaces β the same pattern as Laravel Excel β giving you full control with zero magic.
Installation
composer require filipefernandes/laravel-turbo-excel
Performance Benchmark
When measured against maatwebsite/excel (powered by PhpSpreadsheet), TurboExcel consistently demonstrates massive reductions in memory overhead while streaming exports directly to the disk. Below are the results of a standard benchmark comparing both packages running the exact same export structures.
Data Source: Database Query (FromQuery)
(Both utilizing WithChunkSize, memory limit disabled)
| Dataset Size | Rows | Package | Time (seconds) | Peak Memory | OOM Risk? |
|---|---|---|---|---|---|
| Small | 1,000 | turbo-excel |
**~ 0.5s** | **~ 1 MB** | π’ None |
maatwebsite |
~ 2.5s | ~ 28 MB | π’ None | ||
| Medium | 10,000 | turbo-excel |
**~ 4.0s** | **~ 1 MB** | π’ None |
maatwebsite |
~ 25.0s | ~ 60 MB | π‘ Moderate | ||
| Large | 100,000 | turbo-excel |
**~ 45.0s** | **~ 1 MB** | π’ None |
maatwebsite |
> 150.0s | > 300 MB | π΄ High | ||
| Enormous | 500,000 | turbo-excel |
**~ 220.0s** | **~ 1 MB** | π’ None |
maatwebsite |
Crashes | Exhausted | π Guaranteed |
Note: TurboExcel keeps an entirely flat memory profile because OpenSpout immediately writes XML nodes sequentially to disk without buffering sheet contents in memory.
Core Concept
Create a plain PHP class and implement the concerns you need:
// app/Exports/UsersExport.php use TurboExcel\Concerns\FromQuery; use TurboExcel\Concerns\WithHeadings; use TurboExcel\Concerns\WithMapping; use TurboExcel\Concerns\WithTitle; use TurboExcel\Concerns\WithChunkSize; class UsersExport implements FromQuery, WithTitle, WithHeadings, WithMapping, WithChunkSize { public function query(): Builder { return User::query()->where('active', true); } public function title(): string { return 'Active Users'; } public function headings(): array { return ['Name', 'Email', 'Joined']; } public function map($row): array { return [$row->name, $row->email, $row->created_at->format('Y-m-d')]; } public function chunkSize(): int { return 500; } }
Then call it from a controller:
use TurboExcel\Facades\TurboExcel; // Stream directly to browser (Fastest, zero-IO) return TurboExcel::stream(new UsersExport(), 'users.xlsx'); // Stream to browser via local temporary file return TurboExcel::download(new UsersExport(), 'users.xlsx'); // Write to a Storage disk TurboExcel::store(new UsersExport(), 'exports/users.xlsx', disk: 's3'); // Write to the filesystem TurboExcel::export(new UsersExport(), storage_path('exports/users.xlsx'));
Or using the Exportable trait
Add the Exportable trait to your export class to trigger exports fluently:
use TurboExcel\Concerns\Exportable; class UsersExport implements FromQuery { use Exportable; // ... } // Then in your controller: return (new UsersExport())->download('users.xlsx');
Data Source Concerns
Implement one of the following to tell Turbo-Excel where your data comes from.
| Concern | Method | Best for |
|---|---|---|
FromQuery |
query(): Builder |
Large datasets β streamed via lazy(), flat memory |
FromCollection |
collection(): Collection |
Small, pre-loaded Illuminate Collections |
FromArray |
array(): array |
Plain PHP arrays |
FromGenerator |
generator(): \Generator |
Custom lazy sources, external API pages |
Formatting Concerns
WithHeadings
Explicit column headers written as the first row:
class UsersExport implements FromArray, WithHeadings { public function headings(): array { return ['Full Name', 'Email']; } // ... }
If
WithHeadingsis not implemented, headings are auto-derived from the first row's array keys.
WithMapping
Transform each row before it is written. Combine with WithHeadings for labelled columns:
class UsersExport implements FromQuery, WithHeadings, WithMapping { public function headings(): array { return ['Name', 'Email', 'Joined']; } public function map($user): array { return [$user->name, $user->email, $user->created_at->toDateString()]; } }
WithTitle
Set the sheet tab name (XLSX only):
class UsersExport implements FromArray, WithTitle { public function title(): string { return 'Users'; } }
WithChunkSize
Override the default chunk size when using FromQuery:
class BigExport implements FromQuery, WithChunkSize { public function chunkSize(): int { return 2000; } }
WithColumnFormatting (XLSX only)
Set native Excel number formats (Currency, Dates, etc.) without losing precision:
use TurboExcel\Concerns\WithColumnFormatting; class FinancialExport implements FromArray, WithColumnFormatting { public function array(): array { ... } public function columnFormats(): array { return [ 'B' => '#,##0.00 β¬', // Using column letter 2 => 'dd/mm/yyyy', // Using 0-based index ]; } }
WithStyles (XLSX only)
Apply rich visual styles (fonts, colors, borders) while streaming row-by-row (zero memory overhead):
use TurboExcel\Concerns\WithStyles; use OpenSpout\Common\Entity\Style\Style; class StyledExport implements FromArray, WithStyles { public function array(): array { ... } public function styles(): array { return [ // Style the special first header row 'header' => (new Style())->setFontBold()->setBackgroundColor('FFE6E6E6'), // Style an entire column 'A' => (new Style())->setFontItalic(), ]; } }
WithCsvOptions (CSV only)
Customize delimiter, enclosure, and Unicode BOM settings for CSV files:
use TurboExcel\Concerns\WithCsvOptions; class CustomCsvExport implements FromQuery, WithCsvOptions { public function query() { ... } public function delimiter(): string { return ';'; } public function enclosure(): string { return "'"; } public function addBom(): bool { return true; } // Better UTF-8 support in Excel }
WithAnonymization π‘οΈ
Declaratively mask sensitive data (PII/GDPR) across your export without cluttering your mapping logic. It uses a high-performance in-place replacement loop.
use TurboExcel\Concerns\WithAnonymization; class UsersExport implements FromQuery, WithAnonymization { public function query() { ... } // Optional: Determine if anonymization should run (defaults to true) public function isAnonymizationEnabled(): bool { return $this->user->isAdmin() === false; } // Column keys to mask (mapped or raw keys) public function anonymizeColumns(): array { return ['email', 'phone_number', 'address']; } // Replacement string (default is empty string) public function anonymizeReplacement(): string { return '[HIDDEN]'; } }
Multi-Sheet Exports (XLSX only)
Implement WithMultipleSheets and return an array of sheet export objects. Each sheet object may implement any concern independently.
TurboExcel::download(new ReportExport(), 'monthly-report.xlsx');
WithQuerySplitBySheet β‘
Optimized for large datasets sitting in a single flat table. It uses one single ordered query and automatically splits it into multiple sheets. You can return different sheet handler objects for different segments of the query.
Important
Your query must be ordered by the column you are splitting by.
use TurboExcel\Concerns\WithQuerySplitBySheet; use TurboExcel\Concerns\WithHeadings; use TurboExcel\Concerns\WithMapping; use TurboExcel\Concerns\WithTitle; class FastMultiSheetExport implements WithQuerySplitBySheet { public function query() { // 1. Query must be ordered return DB::table('reports')->orderBy('category'); } public function splitByColumn(): string { // 2. The column triggers a new sheet whenever its value changes return 'category'; } public function sheet(mixed $row): object { // 3. Return a handler for this sheet. // It can implement any concern (Headings, Mapping, Styles, etc.) return match($row->category) { 'VIP' => new VipSheetHandler(), 'Standard' => new StandardSheetHandler(), default => new DefaultSheetHandler(), }; } } class VipSheetHandler implements WithTitle, WithHeadings, WithMapping { public function title(): string { return 'β VIP Report'; } public function headings(): array { return ['VIP Name', 'Priority']; } public function map($row): array { return [$row->name, $row->level]; } }
Output Methods
| Method | Description |
|---|---|
TurboExcel::stream($export, 'file.xlsx') |
Stream directly to browser (Fastest, zero-IO) |
TurboExcel::download($export, 'file.xlsx') |
Stream to browser via local temporary file |
TurboExcel::store($export, 'path/file.xlsx', 's3') |
Write to a Storage disk |
TurboExcel::export($export, '/abs/path/file.xlsx') |
Write to filesystem path |
TurboExcel::queue($export, 'path/file.xlsx', 's3') |
Dispatch to a Laravel Queue |
Import
| Method | Description |
|---|---|
TurboExcel::import($import, '/abs/path.csv', ?Format) |
Stream import from a filesystem path β returns TurboExcel\Import\Result when synchronous, or Illuminate\Bus\Batch when the import uses TurboExcel\Import\Concerns\ShouldQueue (see Imports) |
The export format is auto-detected from the filename extension (.xlsx β XLSX, .csv β CSV).
Pass an explicit Format case to override:
use TurboExcel\Enums\Format; TurboExcel::download(new UsersExport(), 'users.xlsx', Format::CSV);
Imports (CSV & XLSX)
Imports are streamed row-by-row for predictable memory use. Internally, CSV is read with PHPβs fgetcsv; XLSX is read with OpenSpout on the first worksheet only. Readers always yield 0-indexed cell arrays (0 => 'Alice', 1 => 'alice@example.com'). Associative rows and header logic run in the import pipeline only when you opt in via concerns β nothing assumes a header row unless you implement WithHeaderRow.
Basic usage
use TurboExcel\Facades\TurboExcel; use TurboExcel\Import\Concerns\ToModel; use TurboExcel\Import\Concerns\WithHeaderRow; use TurboExcel\Import\Concerns\WithMapping; use TurboExcel\Import\Concerns\WithNormalizedHeaders; use TurboExcel\Import\Concerns\WithValidation; class UsersImport implements ToModel, WithHeaderRow, WithNormalizedHeaders, WithMapping, WithValidation { public function headerRow(): int { return 1; } public function headerNormalization(): array|callable { return fn (string $header): string => strtolower(trim($header)); } public function map(array $row): array { return [ 'name' => trim($row['name']), 'email' => strtolower($row['email']), ]; } public function rules(): array { return [ 'email' => 'required|email', ]; } public function model(array $row): ?User { return new User($row); } } $result = TurboExcel::import(new UsersImport(), storage_path('imports/users.csv'));
You can also return a fixed map from trimmed spreadsheet headers to attribute keys (any header not listed keeps the trimmed text as the key):
public function headerNormalization(): array { return [ 'FULL NAME' => 'name', 'Email Address' => 'email', ]; }
When the import runs synchronously, $result is a TurboExcel\Import\Result:
| Property | Meaning |
|---|---|
processed |
Rows that completed the pipeline (validation + persist / collection append, if applicable) |
failed |
Rows that failed but were handled via SkipsOnFailure |
rows |
Illuminate\Support\Collection of associative arrays (after map + validate) when the import (or any sheet import in a multi-sheet run) uses ToCollection. Multi-sheet sync imports concatenate sheets in workbook order; otherwise null |
The file format is inferred from the extension (.csv / .xlsx). Pass an explicit TurboExcel\Enums\Format as the third argument to override.
Import concerns (TurboExcel\Import\Concerns)
| Concern | Role |
|---|---|
ToModel |
model(array $row): ?Model β return null to skip persisting that row |
ToCollection |
Marker: each successful row (after map + validate) is pushed onto $result->rows. Use instead of ToModel when you want an in-memory collection, or combine with ToModel to persist and collect. Not compatible with ShouldQueue. |
OnEachRow |
onRow(array $row): void β process each row instantly via callback after it is mapped and validated |
OnEachChunk |
onChunk(Collection $chunk): void β process parsed rows in raw chunks. Combined with WithChunkReading to configure chunk size |
WithMapping |
map(array $row): array β transform the row before validation / model() |
WithValidation |
rules(): array β Laravel validator rules (keys match your mapped row) |
WithHeaderRow |
headerRow(): int β 1-based row index of the header (first row after a UTF-8 BOM is row 1) |
WithNormalizedHeaders |
headerNormalization() returns either an associative map ('Header In File' => 'attr_key', trimmed text as keys; unmapped headers keep the trimmed string) or a callable fn (string $header): string; duplicate keys (name, name_1, β¦) and empty headers (column_1, β¦) are still applied afterward |
WithHeaderValidation |
headers(): array β validate the header row (keys are normalized header names) |
WithChunkReading |
chunkSize(): int β used together with queued imports to split work |
ShouldQueue |
Marker: run the import via the queue (see below). Not Laravelβs Illuminate\Contracts\Queue\ShouldQueue β alias this interface in your import class if you also implement queued jobs |
SkipsOnFailure |
onFailure(array $row, \Throwable $e): void β record failures instead of stopping the import |
LogsFailuresToCsv |
Trait: implement SkipsOnFailure on your class and use LogsFailuresToCsv; to automatically dump failed rows + error messages into a local CSV file |
SkipsEmptyRows |
Marker: automatically skips and ignores completely empty/blank rows before mapping and validation |
WithBatchInserts |
batchSize(): int β buffer rows and insert in batches (no per-row save(); events/casts limitations apply as with raw insert) |
WithUpserts |
uniqueBy(): array|string β combined with WithBatchInserts, switches batch inserts into bulk upsert() queries based on this unique key |
WithUpsertColumns |
upsertColumns(): ?array β used with WithUpserts to explicitly declare which columns should be updated when an upsert collision occurs |
WithStartRow |
startRow(): int β start reading data from this spreadsheet row (default is 1) |
WithLimit |
limit(): int β stop reading after this many rows have been processed |
WithMetrics |
Marker (or call ->withMetrics()): logs performance data (memory, timing, row counts) to your Laravel log |
Importable |
Trait: adds import(), queue(), and withMetrics() methods for a fluent API |
RemembersRowNumber |
Trait + Interface: automatically tracks the physical row index. Ensure your class implements TurboExcel\Import\Concerns\RemembersRowNumber and uses the trait |
WithMultipleSheets |
XLSX only. sheets(): array returns a list of import objects, one per worksheet in workbook order (index 0 = first sheet). Put ToModel, ToCollection, headers, mapping, validation, and optional WithChunkReading on each sheet import β the coordinator should normally implement only WithMultipleSheets (and optionally ShouldQueue). Not valid for CSV. |
Pipeline order per row: optional header β associative combine β map β validate β model / batch insert, and append to $result->rows when ToCollection is implemented.
Multi-sheet XLSX imports
use TurboExcel\Import\Concerns\WithMultipleSheets; class WorkbookImport implements WithMultipleSheets { public function sheets(): array { return [ new UsersSheetImport(), // first worksheet new OrdersSheetImport(), // second worksheet ]; } } $result = TurboExcel::import(new WorkbookImport(), storage_path('workbook.xlsx')); // $result->processed / $result->failed are totals across all sheets // $result->rows concatenates rows from each sheet import that uses ToCollection
Queued multi-sheet imports dispatch one or more chunk jobs per sheet (each job carries a sheetIndex on XlsxReadSegment). WithChunkReading / chunkSize() are read from each sheetβs import object when present.
CSV options & BOM
- Implement
TurboExcel\Concerns\WithCsvOptionson the import class to set delimiter and enclosure (same concern as exports). - A UTF-8 BOM (
EF BB BF) is consumed only when the reader opens the file at byte offset 0, so the first column is not corrupted. Chunk jobs that start later in the file do not strip the BOM again.
Queued & chunked imports
If your import implements TurboExcel\Import\Concerns\ShouldQueue:
ToCollectionis not supported with queued imports (there is no single mergedCollectionacross workers). RemoveShouldQueueor useToModel/ a synchronous import.- With
WithChunkReading, TurboExcel performs one scan of the file, then dispatchesTurboExcel\Import\Jobs\ProcessChunkJobinstances inside aIlluminate\Bus\Batch. CSV chunks use byte ranges in the raw file; XLSX chunks use inclusive row index ranges per worksheet (XlsxReadSegmentincludes a 0-basedsheetIndex). - Without
WithChunkReading, a single queued job processes the whole file (or one job per sheet when using importWithMultipleSheets).
The import() method returns an Illuminate\Bus\Batch when jobs are dispatched. Each job increments cache counters under:
turbo_excel_import:{uuid}:processedturbo_excel_import:{uuid}:failed
(The UUID is shared by all jobs in that batch.) If the file has no data rows (for example header-only CSV), you get a Result with zero counts and no batch is dispatched.
Format Enum
use TurboExcel\Enums\Format; Format::XLSX // application/vnd.openxmlformats-... Format::CSV // text/csv
Queued Background Exports
When exporting massive amounts of data, you can dispatch the export to a Laravel Queue entirely in the background. Because TurboExcel uses OpenSpout, memory is kept incredibly flat, handling millions of rows efficiently within a single job.
TurboExcel::queue(new UsersExport(), 'reports/huge-report.xlsx', 's3') ->onConnection('redis') ->onQueue('exports');
Using with Laravel Batches (Progress Tracking)
You can also dispatch the export job directly inside a Laravel Batch to track completion percentages! TurboExcel automatically breaks your data into chunks and updates the batch progress internally as it writes to the file.
use Illuminate\Support\Facades\Bus; use TurboExcel\Jobs\ExportJob; $batch = Bus::batch([ new ExportJob(new UsersExport(), 'reports/users.xlsx', disk: 's3') ])->name('Daily Users Export')->dispatch(); // Later check progress in your UI echo $batch->progress(); // e.g. 50%
Debugging / Profiling
When dealing with extremely large datasets (millions of rows) in local development, it can be useful to see exactly how much memory the OpenSpout stream is consuming and how fast the database is chunking rows.
Implement the TurboExcel\Concerns\WithDebug interface on your export class to automatically emit detailed debugging logs to your default Laravel Log (storage/logs/laravel.log).
Testing
composer test
License
MIT