Laravel Excel (maatwebsite/excel) เป็น package ที่งาน Laravel ส่วนใหญ่ใช้ export Excel ข้างในใช้ PhpSpreadsheet ทั้งหมด จึงทำได้ทุกอย่างที่ PhpSpreadsheet ทำได้ และใช้ memory แบบเดียวกัน (ถ้ายังไม่แน่ใจว่างานนั้นควรใช้ตัวนี้ไหม ลองอ่านวิธีเลือก package export Excel ก่อน) โพสต์นี้เขียนตามเวอร์ชัน 4.0 และบอกจุดที่ 3.1 ต่างกันไว้ด้วย
export หนึ่งไฟล์เขียนเป็นหนึ่งคลาส
concern แต่ละตัวเป็น interface ที่บอก Laravel Excel ว่าคลาสนี้ทำอะไรได้ ตัวอย่างนี้ดึงออร์เดอร์ตามช่วงวันที่ มีหัวตาราง วันที่เป็นวันที่จริงของ Excel และยอดเงินมี comma
namespace App\Exports;
use App\Models\Order;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Builder;
use Maatwebsite\Excel\Concerns\Exportable;
use Maatwebsite\Excel\Concerns\FromQuery;
use Maatwebsite\Excel\Concerns\ShouldAutoSize;
use Maatwebsite\Excel\Concerns\WithColumnFormatting;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithMapping;
use Maatwebsite\Excel\Concerns\WithStrictNullComparison;
use Maatwebsite\Excel\Concerns\WithStyles;
use PhpOffice\PhpSpreadsheet\Shared\Date;
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
class OrdersExport implements FromQuery, WithHeadings, WithMapping, WithColumnFormatting,
WithStyles, ShouldAutoSize, WithStrictNullComparison
{
use Exportable;
public function __construct(
private CarbonImmutable $from,
private CarbonImmutable $to,
) {}
public function query(): Builder
{
return Order::query()
->with('customer')
->whereBetween('ordered_at', [$this->from, $this->to])
->orderBy('id');
}
public function headings(): array
{
return ['เลขที่', 'ลูกค้า', 'วันที่สั่ง', 'ยอดเงิน'];
}
public function map(mixed $order): array
{
return [
$order->number,
$order->customer->name,
Date::dateTimeToExcel($order->ordered_at),
$order->total,
];
}
public function columnFormats(): array
{
return [
'C' => 'd mmm yyyy',
'D' => NumberFormat::FORMAT_NUMBER_COMMA_SEPARATED2,
];
}
public function styles(Worksheet $sheet): array
{
return [1 => ['font' => ['bold' => true]]];
}
}
Route::get('/orders/export', fn () => (new OrdersExport(
CarbonImmutable::parse(request('from')),
CarbonImmutable::parse(request('to')),
))->download('orders.xlsx'));
interface ใน 3.1 ยังไม่มี type เช่น map($row) แต่ PHP ยอมให้คลาสใส่ type เพิ่มเองได้ ตัวอย่างข้างบนจึงใช้กับ 3.1 ได้เหมือนกัน
เลือกแหล่งข้อมูลให้ตรงกับขนาดงาน
| Concern | ให้ข้อมูลยังไง | เหมาะกับ |
|---|---|---|
FromArray |
คืน array ของแถว | ข้อมูลที่คำนวณเสร็จแล้ว เช่นรายงานสรุป |
FromCollection |
คืน collection | ข้อมูลไม่มาก ดึงมาทั้งก้อนได้ |
FromQuery |
คืน query ให้ package ดึงเป็น chunk ละ 1,000 แถว | ตารางใหญ่จากฐานข้อมูล |
FromGenerator |
yield ทีละแถว |
ข้อมูลจาก API หรือไฟล์ที่ไม่ใช่ query |
FromView |
Blade view ที่เป็นตาราง HTML | หน้าตาซับซ้อนที่มีอยู่แล้วเป็น HTML |
FromQuery ลดจำนวน model ที่โหลดไว้พร้อมกัน แต่ทุกแถวยังไปจบใน memory ของ PhpSpreadsheet อยู่ดี ส่วน FromView ต้องแปลง HTML ทั้งหน้าเป็น DOM ก่อนถึงจะเป็น cell ได้ จึงหนักที่สุด ใช้กับรายงานเล็กที่ต้องจัดหัวตารางซับซ้อนเท่านั้น ถ้าตารางใหญ่หลายหมื่นแถว ควรเปลี่ยนเป็น FromQuery หรือย้ายไปเขียนทีละแถวด้วย OpenSpout
ขนาด chunk ของ FromQuery ตั้งได้ด้วย WithCustomChunkSize เท่านั้น ส่วน WithChunkReading เป็น concern ของการ import ถ้าใส่บนคลาส export ก็คอมไพล์ผ่าน แต่ package ไม่ได้อ่าน และยังใช้ค่าเริ่มต้นอยู่ (ดูได้ใน Sheet.php ของ package ที่เช็กแค่ WithCustomChunkSize)
concern ที่ใช้จัดหน้าตาและชนิดข้อมูล
WithHeadingsแถวหัวตาราง คืนได้หลายแถวถ้าเป็น array ซ้อนWithMappingแปลง model หนึ่งตัวเป็นหนึ่งแถว ที่นี่คือที่แปลงวันที่เป็นวันที่ของ Excel และเลือกคอลัมน์ (วันที่ใน Excel มีรายละเอียดอีกเยอะ)WithColumnFormattingรูปแบบตัวเลขต่อคอลัมน์ ใช้ code แบบเดียวกับใน Format Cells ของ ExcelWithStylesstyle ตามแถว คอลัมน์ หรือช่วง เป็น array แบบเดียวกับapplyFromArray()ของ PhpSpreadsheetShouldAutoSizeให้ PhpSpreadsheet ประมาณความกว้างคอลัมน์ ถ้าใส่WithColumnWidthsด้วย ความกว้างที่กำหนดเองจะชนะ (ภาษาไทยทำให้ autosize กว้างเกิน)WithStrictNullComparisonใส่ไว้แทบทุก export เพราะถ้าไม่ใส่ package จะเทียบค่าด้วย==แล้วเลข0กลายเป็นช่องว่างWithTitleชื่อชีท ยาวได้ไม่เกิน 31 ตัวอักษรWithEventsสำหรับสิ่งที่ concern อื่นทำไม่ได้ เช่น freeze แถวหัว merge cell หรือ conditional formatting ผ่านAfterSheet
public function registerEvents(): array
{
return [
AfterSheet::class => function (AfterSheet $event): void {
$sheet = $event->sheet->getDelegate();
$sheet->freezePane('A2');
$sheet->setAutoFilter($sheet->calculateWorksheetDimension());
},
];
}
เลขที่ขึ้นต้นด้วยตัวเลขต้องบังคับเป็นข้อความ
เลขบัตรประชาชน เลขผู้เสียภาษี หรือเลขเอกสารที่เป็นตัวเลขล้วน ถ้าส่งเป็น string ธรรมดา PhpSpreadsheet จะเดาว่าเป็นตัวเลข แล้ว Excel แสดงเป็น 1.23E+12 ตั้ง format @ ทีหลังก็ช่วยแค่การแสดงผล ค่าที่เก็บยังเป็นตัวเลข ทางที่ได้ผลคือ WithCustomValueBinder ที่เขียนค่าเป็น string ตั้งแต่แรก
class CustomersExport extends DefaultValueBinder implements FromQuery, WithCustomValueBinder
{
public function bindValue(Cell $cell, mixed $value): bool
{
if (in_array($cell->getColumn(), ['B', 'C'], true) && $value !== null) {
$cell->setValueExplicit((string) $value, DataType::TYPE_STRING);
return true;
}
return parent::bindValue($cell, $value);
}
// query(), headings() ...
}
เรื่องนี้มีหลายกรณีกว่านี้ อ่านต่อได้ที่เลขบัตรประชาชนกลายเป็น 1.23E+12
แยกชีทด้วย WithMultipleSheets
คลาสหลักคืนรายการชีท แต่ละชีทเป็นคลาส export ของตัวเอง
use Maatwebsite\Excel\Concerns\Export;
use Maatwebsite\Excel\Concerns\WithMultipleSheets;
class MonthlyReport implements Export, WithMultipleSheets
{
public function sheets(): array
{
return [
new SummarySheet($this->month),
new OrdersSheet($this->month),
];
}
}
ใน 4.0 ต้องมี implements Export ด้วย เพราะ WithMultipleSheets ไม่ได้สืบจาก Export เหมือน concern อื่น ถ้าลืม Excel::download() ที่รับ Export จะโยน TypeError ส่วน 3.1 ไม่ต้องมี
ส่งไฟล์ออกได้สี่ทาง
| คำสั่ง | ได้อะไร |
|---|---|
Excel::download($export, 'file.xlsx') |
ดาวน์โหลดทันทีใน request เดียว |
Excel::store($export, 'reports/file.xlsx', 's3') |
เขียนลง disk ของ Laravel |
Excel::queue($export, 'reports/file.xlsx') |
แบ่งเป็น job ใน queue แล้วเขียนลง disk |
Excel::raw($export, \Maatwebsite\Excel\Excel::XLSX) |
ได้เนื้อไฟล์เป็น string เอาไปแนบอีเมลหรือส่งต่อ |
queue() กับ FromQuery จะแบ่งงานเป็น job ละ chunk และทุก job ต้องเปิดไฟล์ชั่วคราวขึ้นมาใหม่ด้วย PhpSpreadsheet ก่อนเติมแถว (ดูได้ใน AppendQueryToSheet) ไฟล์ยิ่งโต job หลัง ๆ ยิ่งช้าและกิน memory มากขึ้น สำหรับไฟล์ใหญ่จริง การเขียนทีละแถวใน job เดียวมักจบเร็วกว่า วิธีส่งไฟล์ให้ผู้ใช้แบบต่าง ๆ อยู่ในวิธีส่งไฟล์ Excel ให้ผู้ใช้ใน Laravel
อัปเกรดจาก 3.1 เป็น 4.0
4.0 ออกเมื่อสิงหาคม 2569 ส่วน 3.1 ตอนนี้รับแค่แก้ช่องโหว่ สิ่งที่ต้องเช็กก่อนอัปเกรดตามคู่มืออัปเกรด
- PHP 8.3 ขึ้นไป และ Laravel 12 หรือ 13
- PhpSpreadsheet ขยับจาก 1.30 เป็น 5.x โค้ดที่เรียก PhpSpreadsheet ตรง ๆ ต้องตรวจ เช่นใน
AfterSheet, value binder,WithDrawings,WithChartsเมธอดแบบsetCellValueByColumnAndRow()ถูกลบไปตั้งแต่ PhpSpreadsheet 2.0 แล้ว - ทุก signature มี type คลาสที่ implement concern ต้องใส่ type ให้ตรง เช่น
map(mixed $row): array WithMultipleSheetsต้องimplements Exportตามหัวข้อก่อนหน้าFromQueryไม่รับ Scout builder แล้ว ให้ใช้FromScoutแทนconfig/excel.phpไม่มี key เปลี่ยน ไม่ต้อง publish ใหม่
ของใหม่ที่น่าใช้มีสามอย่าง
WithColumnsอธิบายชีทเป็นรายการคอลัมน์ที่มีชนิดของตัวเอง แทนWithHeadings+WithMapping+WithColumnFormattingคอลัมน์Textเขียนเป็นข้อความเสมอ เลขบัตรจึงไม่กลายเป็นตัวเลขอีกShouldBatchส่ง export ที่เข้า queue เป็น job batch แทน chain จึงได้PendingBatchกลับมาไว้ดูความคืบหน้า#[Queue]และ#[Connection]attribute ของ Laravel 13 ใช้กำหนด queue ของ import ได้
use Maatwebsite\Excel\Columns\Date;
use Maatwebsite\Excel\Columns\Decimal;
use Maatwebsite\Excel\Columns\Text;
use Maatwebsite\Excel\Concerns\WithColumns;
class CustomersExport implements FromQuery, WithColumns
{
public function columns(): array
{
return [
Text::make('รหัสลูกค้า', 'code'),
Text::make('เลขผู้เสียภาษี', 'tax_id')->width(18),
Date::make('วันที่สมัคร', 'created_at'),
Decimal::make('วงเงิน', 'credit_limit')->autoSize(),
];
}
// query() ...
}
WithColumns ใช้คู่กับ WithHeadings หรือ WithMapping ไม่ได้ เพราะคอลัมน์บอกทั้งหัวตารางและค่าไว้แล้ว ส่วน WithStyles ยังใช้จัด style แถวหัวได้เหมือนเดิม
ปัญหาที่เจอบ่อยกับ Laravel Excel
- แถวว่างหาย style เลื่อน คืน
[]เป็นแถวคั่นในFromArrayแล้ว package ตัดทิ้ง style ทั้งหมดที่อ้างเลขแถวจะเลื่อนขึ้นหนึ่งแถว ให้คืน['']แทน - ยอด 0 แสดงเป็นช่องว่าง ลืม
WithStrictNullComparison - รายงานที่ช้าเพราะ style ไม่ใช่เพราะ query การตั้งความสูงทีละแถว หรือใส่เส้นขอบทุกช่องในไฟล์หลายหมื่นแถว อาจกินเวลาเป็นนาที ใช้ default row height ของทั้งชีทแทน อย่างในระบบรายงานจาก SAP ที่เราทำให้ลูกค้า แค่เปลี่ยนจุดนี้ก็ลดเวลาไปราวหนึ่งนาทีในรายงาน 45,000 แถว
- memory หมดกลางทาง เป็นข้อจำกัดของ PhpSpreadsheet ไม่ใช่ของ concern ตัวใด ดูวิธีรับมือได้ที่ export Excel แล้ว memory หมด






