วิธีเขียน export ด้วย Laravel Excel ตั้งแต่ 3.1 ถึง 4.0

Laravel Excel ให้เขียนหนึ่งคลาสต่อหนึ่งไฟล์ แล้วเลือก concern ตามที่ต้องการ เช่น FromQuery ดึงข้อมูล WithMapping จัดแถว WithColumnFormatting ใส่รูปแบบตัวเลข และ WithMultipleSheets แยกชีท ส่วน 4.0 เพิ่ม WithColumns กับ ShouldBatch และต้องใช้ PHP 8.3 กับ Laravel 12 ขึ้นไป

เผยแพร่
เวลาอ่าน
5 นาที
ในโพสต์นี้ 8 หัวข้อ
  1. export หนึ่งไฟล์เขียนเป็นหนึ่งคลาส
  2. เลือกแหล่งข้อมูลให้ตรงกับขนาดงาน
  3. concern ที่ใช้จัดหน้าตาและชนิดข้อมูล
  4. เลขที่ขึ้นต้นด้วยตัวเลขต้องบังคับเป็นข้อความ
  5. แยกชีทด้วย WithMultipleSheets
  6. ส่งไฟล์ออกได้สี่ทาง
  7. อัปเกรดจาก 3.1 เป็น 4.0
  8. ปัญหาที่เจอบ่อยกับ Laravel Excel

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]]];
    }
}

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 ของ Excel
  • WithStyles style ตามแถว คอลัมน์ หรือช่วง เป็น array แบบเดียวกับ applyFromArray() ของ PhpSpreadsheet
  • ShouldAutoSize ให้ 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 หมด

ตอนถัดไปของซีรีส์ Export Excel และ CSV จาก Laravel

อ่านต่อ

ถูกอ้างถึงใน

ล่าสุด

ดูทั้งหมด →