A Laravel Excel export takes three moves. Install maatwebsite/excel, generate a class with php artisan make:export, and return Excel::download(new OrdersExport, 'orders.xlsx') from a controller. Imports mirror the same shape with make:import and Excel::import().

That part is easy. The part that breaks in production is everything after it.

Most Laravel apps that touch reporting end up with Maatwebsite Excel in composer.json, and most teams stop reading the docs the moment the first download works. Then the export meets a real table, PHP hits its memory limit, and a report that ran fine in staging starts returning a 500 at month end.

This guide covers the whole path, including the parts nobody writes down. You get query backed exports, imports with validation and chunked reading, CSV output that streams instead of buffering, queued jobs for large files, formula injection defence, and Pest tests that keep it all working after the next refactor.

Key TakeawaysUse FromQuery instead of FromCollection for any Laravel Excel export above a few thousand rows, because it lets the package chunk the query rather than hydrate every model at once.Stable imports need three concerns stacked together, WithChunkReading, WithBatchInserts, and WithValidation paired with SkipsOnFailure.Queue anything past roughly 50,000 rows, write the file to a disk, and notify the user instead of holding an HTTP request open.Escape leading =, +, -, and @ characters in exported cells to prevent CSV formula injection in Excel and Google Sheets.For very large flat CSV output, a streamed fputcsv response beats PhpSpreadsheet on memory every time.

Install Maatwebsite Excel and ship your first Laravel Excel export

Start with Composer. The package registers through Laravel package auto discovery, so there is no service provider to wire up by hand.

composer require maatwebsite/excel
php artisan vendor:publish --provider="Maatwebsite\Excel\ExcelServiceProvider" --tag=config

Publishing the config is optional and still worth doing. It holds chunk sizes, CSV delimiters, and the temporary file paths you will need once exports move to a queue. Laravel Excel 3.1 has tracked framework releases for years and runs across Laravel 9 through 12, so confirm the version constraint in the official Laravel Excel documentation before you upgrade an older project.

Now generate the export class. The --model flag scaffolds it against an Eloquent model, the same way the rest of the Artisan make commands behave.

php artisan make:export OrdersExport --model=Order

You get a class in app/Exports that implements FromCollection. Return rows, hand the object to the facade, and the framework does the rest.

use App\Exports\OrdersExport;
use Maatwebsite\Excel\Facades\Excel;

public function export(OrderExportRequest $request)
{
 $this->authorize('viewAny', Order::class);

 return Excel::download(
 new OrdersExport($request->validated('from'), $request->validated('to')),
 'orders.xlsx'
 );
}

Two files and a route, and your first Laravel Excel export works. That is where most tutorials end, and for a settings screen that exports 200 rows it is genuinely enough. Everything below is what you add when the row count stops being polite.

Writing the export class, the FormRequest, the policy check, and the test by hand is twenty minutes you have spent before. You can also describe the report in plain English and get production-ready Laravel code back instead. Get started free and generate the first one.

Build a Laravel Excel export that survives real data

A Laravel Excel export that handles production volume differs from the starter version in one place, how rows reach the writer.

Why FromCollection quietly hurts

FromCollection runs the query, hydrates every model into memory, then hands the whole collection to PhpSpreadsheet, which builds an in memory object for every cell. Two large structures now share one PHP process. At 5,000 rows nobody notices. At 250,000 rows the request dies on a memory limit, usually with a stack trace pointing at the writer rather than at your code.

FromQuery changes the shape of the problem. You return a builder instead of a result set, and the package chunks through it, so only one slice is resident at a time.

namespace App\Exports;

use App\Models\Order;
use Illuminate\Database\Eloquent\Builder;
use Maatwebsite\Excel\Concerns\FromQuery;
use Maatwebsite\Excel\Concerns\ShouldAutoSize;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithMapping;

class OrdersExport implements FromQuery, WithHeadings, WithMapping, ShouldAutoSize
{
 public function __construct(
 private readonly string $from,
 private readonly string $to,
 ) {}

 public function query(): Builder
 {
 return Order::query()
 ->with('customer')
 ->whereBetween('created_at', [$this->from, $this->to]);
 }

 public function headings(): array
 {
 return ['Order ID', 'Customer', 'Total', 'Placed at'];
 }

 public function map($order): array
 {
 return [
 $order->id,
 $order->customer->name,
 number_format($order->total / 100, 2),
 $order->created_at->toDateTimeString(),
 ];
 }
}

Headings, mapping, and the N+1 trap

WithHeadings writes the first row. WithMapping decides what each record becomes, which is where you format money, flatten relationships, and drop the columns nobody asked for. ShouldAutoSize measures every column and costs real time on wide sheets, so skip it past roughly 50 columns.

Watch the relationship access inside map(). Calling $order->customer->name without the matching with('customer') means one extra query per row, which turns a 90 second export into a 20 minute one. Eager load everything the mapper touches. This bug never shows up in a small test file, which is exactly why it reaches production.

A team lead at a logistics company found this the hard way. Their orders export ran cleanly through three months of staging data, roughly 900 rows, then hit its first production month end with 240,000 orders and returned a 500 after ninety seconds. Swapping FromCollection for FromQuery, eager loading the customer relationship, and moving the job to a queue got the same report finishing in the background with a flat memory profile. The business logic never moved.

Handle a Laravel Excel import with validation and chunked reading

Imports carry more risk than exports. You are accepting a file a human edited, which means merged cells, dates stored as text, a stray title row above the headers, and at least one price written as "N/A".

Validate the upload before the parser ever sees it.

$request->validate([
 'catalog' => ['required', 'file', 'mimes:xlsx,csv,txt', 'max:10240'],
]);

Include txt in that mime list. PHP frequently reports a plain CSV as text/plain, and leaving it out is the reason half the "my import rejects valid files" bug reports exist.

Then generate the import class and stack only the concerns you need.

php artisan make:import ProductsImport --model=Product
namespace App\Imports;

use App\Models\Product;
use Maatwebsite\Excel\Concerns\SkipsFailures;
use Maatwebsite\Excel\Concerns\SkipsOnFailure;
use Maatwebsite\Excel\Concerns\ToModel;
use Maatwebsite\Excel\Concerns\WithBatchInserts;
use Maatwebsite\Excel\Concerns\WithChunkReading;
use Maatwebsite\Excel\Concerns\WithHeadingRow;
use Maatwebsite\Excel\Concerns\WithValidation;

class ProductsImport implements ToModel, WithHeadingRow, WithValidation, WithChunkReading, WithBatchInserts, SkipsOnFailure
{
 use SkipsFailures;

 public function model(array $row):?Product
 {
 return new Product([
 'sku' => $row['sku'],
 'name' => $row['name'],
 'price' => (int) round($row['price'] * 100),
 ]);
 }

 public function rules(): array
 {
 return [
 'sku' => ['required', 'string', 'max:64'],
 'name' => ['required', 'string', 'max:255'],
 'price' => ['required', 'numeric', 'min:0'],
 ];
 }

 public function chunkSize(): int
 {
 return 1000;
 }

 public function batchSize(): int
 {
 return 1000;
 }
}

Each concern earns its place. WithHeadingRow gives you $row['sku'] instead of $row[0]. WithValidation applies ordinary Laravel rules per row, and SkipsOnFailure with the SkipsFailures trait stops one bad row from aborting the run while collecting the failures for you.

$import = new ProductsImport();

Excel::import($import, $request->file('catalog'));

return back()->with('importFailures', $import->failures()->map(fn ($failure) => [
 'row' => $failure->row(),
 'column' => $failure->attribute(),
 'errors' => $failure->errors(),
]));

Show that list back to the user. A partial import that says rows 14, 88, and 203 were skipped beats a silent rollback every time, especially inside the admin panels and internal tools where these uploads usually live. For repeat uploads of the same catalog, add WithUpserts and a unique key so a second run updates rows instead of duplicating them.

Stream a Laravel CSV export instead of buffering it

Switching format is one argument.

use Maatwebsite\Excel\Excel as ExcelWriter;

return Excel::download(new OrdersExport($from, $to), 'orders.csv', ExcelWriter::CSV);

That still routes through PhpSpreadsheet. For a styled workbook with multiple sheets and formulas, the overhead buys you something real. For a plain Laravel export CSV job with flat rows and no formatting, you are paying a spreadsheet engine to produce a text file.

Approach

Best for

Memory profile

Trade off

FromCollection download

Under 5,000 rows

Grows with row count

Fails on large tables

FromQuery download

Up to roughly 50,000 rows

Chunked and flat

Still holds an HTTP request open

Queued FromQuery export

Large XLSX reports

Flat per worker

Needs a queue and shared temp storage

Streamed fputcsv response

Very large flat CSV

Near constant

No styling, formulas, or sheets

When the last row is the honest answer, skip the package and stream.

return response()->streamDownload(function () {
 $handle = fopen('php://output', 'w');

 fputcsv($handle, ['Order ID', 'Customer', 'Total']);

 Order::query()
 ->with('customer')
 ->lazyById(1000)
 ->each(function (Order $order) use ($handle) {
 fputcsv($handle, [
 $order->id,
 $order->customer->name,
 number_format($order->total / 100, 2),
 ]);
 });

 fclose($handle);
}, 'orders.csv');

Memory stays close to constant because rows leave the process as fast as they arrive. The PhpSpreadsheet maintainers document the same tension in their memory saving guide, where cell caching helps but never matches a stream. Keep the package for files that need genuine spreadsheet features, such as the formatted reports behind an analytics dashboard.

Queue large exports so requests never time out

Any Laravel Excel export a user waits on is a report you will eventually move to a queue. Do it before someone files the timeout ticket.

use Maatwebsite\Excel\Facades\Excel;

Excel::queue(new OrdersExport($from, $to), 'exports/orders.xlsx', 's3')
 ->chain([
 new NotifyUserOfCompletedExport($request->user()),
 ]);

The package splits a FromQuery export into one job per chunk, appends each slice to the same file, then runs your chained job when the final chunk lands. Add the Exportable trait to the export class if you prefer the (new OrdersExport)->queue(...) style. Three things have to be true for any of it to work.

  • Your queue connection cannot be sync, or the job runs inline and you gain nothing.

  • Worker timeout has to comfortably exceed the time a single chunk takes.

  • On multi server setups the temporary files need a disk every worker can reach, which is what temporary_files.remote_disk in config/excel.php is for.

Imports queue the same way. Add ShouldQueue to the import class alongside WithChunkReading, and each chunk becomes its own job with its own retry behaviour. The Laravel queue documentation covers the worker flags that matter here, particularly --timeout and --tries.

An agency team ran a nightly supplier catalog import for a client. The class saved one model per row, so 40,000 products meant 40,000 individual inserts, a 26 minute run, and a worker that hit its timeout about once a week. Adding WithBatchInserts and WithChunkReading at 1,000 rows each turned those inserts into a few hundred statements. The validation rules, the mapping, and the file format all stayed exactly the same.

None of this is difficult. It is a stack of conventions you have to recall at the exact moment you are trying to ship something else. Describing the import once and getting the class, the queued job, the failure handling, and the tests back is the faster route. Try StackMivo Now.

Secure and test your Laravel Excel export

Escape formula characters before they reach the file

A cell beginning with =, +, -, or @ is a formula as far as Excel and Google Sheets are concerned. If a user controls any exported value, such as a display name, a product title, or a ticket subject, they control a formula that runs on a colleague's machine when the file opens. OWASP tracks this as CSV injection, and it applies to XLSX output as much as to CSV.

Neutralise it in the mapper, where every value already passes through.

public function map($order): array
{
 return [
 $order->id,
 $this->safeCell($order->customer->name),
 number_format($order->total / 100, 2),
 $order->created_at->toDateTimeString(),
 ];
}

private function safeCell(?string $value): string
{
 $value = trim((string) $value);

 foreach (['=', '+', '-', '@'] as $prefix) {
 if (str_starts_with($value, $prefix)) {
 return "'".$value;
 }
 }

 return $value;
}

Authorization deserves equal attention. Export routes leak more data than any other endpoint in a typical admin panel, because they return whole tables and rarely get a Policy check during review. Gate them with authorize() and scope the query to what the current user is allowed to see.

Prove it works with Pest

The facade fakes cleanly, so you can assert on the export without writing a file to disk.

use App\Exports\OrdersExport;
use Maatwebsite\Excel\Facades\Excel;

it('downloads the orders export for an admin', function () {
 Excel::fake();

 $this->actingAs(User::factory()->create())
 ->get(route('admin.orders.export', ['from' => '2026-01-01', 'to' => '2026-01-31']))
 ->assertOk();

 Excel::assertDownloaded('orders.xlsx', function (OrdersExport $export) {
 return $export->query()->count() === 3;
 });
});

Use Excel::assertQueued() for queued exports and Excel::assertStored() when the file lands on a disk. Two tests per export, one happy path and one unauthorised user, catch most of what goes wrong six months later.

Ship exports that hold up in production

The rules are short enough to keep in your head. Return a builder with FromQuery rather than a hydrated collection, eager load everything the mapper touches, and chunk plus batch every import. Move heavy jobs to a queue early. Stream flat CSV directly when you need no spreadsheet features at all.

Two habits separate an export that lasts from one that gets rewritten. Escape leading formula characters so a spreadsheet never executes user input, and put a Policy in front of every export route. Then write the two Pest tests that keep both honest.

Most of a solid Laravel Excel export is convention, and convention is exactly what an assistant trained on the framework handles well. Point Laravel-native intelligence at the report you need, then spend your own attention on the query that actually matters. Your next month end close will be a much quieter day.