viest/xlswriter
High-performance PHP extension for reading and writing Excel XLSX files with low memory usage. Built in C on libxlsxwriter, it supports formulas, charts, images, formatting, conditional formatting and data validation—fast, reliable and ideal for large exports.
xlswriter is a PHP C extension (not a Composer package) — there is nothing to require in composer.json. Installation is the first real hurdle:
# Option A: PECL
pecl install xlswriter
# Option B: compile from source (needs php-dev + zlib)
git clone https://github.com/viest/php-ext-xlswriter.git
cd php-ext-xlswriter && phpize && ./configure && make && make install
Then enable it and confirm it loaded:
; php.ini
extension=xlswriter.so ; or xlswriter.dll on Windows
php -m | grep xlswriter # must appear, otherwise every call below is a fatal error
Look first at: the Vtiful\Kernel\Excel class — it is the only entry point. Everything is fluent: fileName() → header() → addSheet()/getHandle() → write*() → output()/close(). The repo's tests/ directory is the de-facto API reference.
First use case — replace PhpSpreadsheet for write-only XLSX export:
use Vtiful\Kernel\Excel;
$config = ['path' => storage_path('app/exports')]; // a DIRECTORY, not a file path
$filePath = (new Excel(['path' => storage_path('app/exports')]))
->fileName('users.xlsx')
->header(['ID', 'Name', 'Created'])
->data([[1, 'Ada', '2026-01-01'], [2, 'Grace', '2026-01-02']])
->output();
output() returns the absolute path of the written file. That is the thing you Storage::download() or response()->download(), not the fileName() string.
Streaming large exports. The reason to pick this extension over PhpSpreadsheet is memory. Use openFile() + row-by-row writeRow() inside a chunked query loop rather than building a giant array:
$excel = new Excel(['path' => $dir]);
$handle = $excel->fileName('big.xlsx')->header(['id', 'name'])->getHandle();
User::query()->chunkById(5000, function ($rows) use ($handle, $excel) {
foreach ($rows as $i => $u) {
$excel->writeRow($i, 0, [$u->id, $u->name]); // or insertText()
}
});
$excel->output();
Note the $i offset: writeRow takes an explicit row index and will silently overwrite if you don't advance it yourself. Track a counter outside the chunk callback.
Column formats via addFormat(). Reuse the resource returned by addFormat() across cells — creating one per cell leaks handles and slows the write:
$bold = $excel->addFormat()->bold()->fontColor('#FFFFFF')->background('#4F81BD')->wrap();
$money = $excel->addFormat()->number('#,##0.00');
$date = $excel->addFormat()->number('yyyy-mm-dd');
$excel->insertText(0, 0, 'Total', $bold);
$excel->insertText(1, 2, 1234.5, $money);
$excel->insertDate(1, 3, time(), $date); // insertDate expects a unix timestamp
insertDate()/insertUrl()/insertChart() exist because a plain insertText cannot express those types — type is decided by the method, not by the PHP value.
Laravel controller download. Write to a temp dir, then hand back a proper response and clean up in terminate():
public function export(): BinaryFileResponse
{
$path = (new Excel(['path' => storage_path('app/tmp')]))
->fileName('report_'.now()->timestamp.'.xlsx')
->header(['a', 'b'])
->data($this->rows())
->output();
return response()->download($path)->deleteFileAfterSend(true);
}
deleteFileAfterSend(true) is essential — this extension never cleans up after itself.
Global default format. If most cells share a style, set it once on the workbook instead of passing a format to every call:
$excel->fileName('x.xlsx')->setGlobalFormat($excel->addFormat()->font('Arial')->fontSize(10));
Freeze panes / autofilter / merges go on the sheet handle, after addSheet() but before writing rows:
$excel = new Excel(['path' => $dir]);
$excel->fileName('x.xlsx')
->addSheet('Q1')
->freezePanes(1, 0) // freeze header row
->autoFilter('A1:D1')
->mergeCells(0, 0, 0, 3, 'Q1 Summary') // (row1,col1,row2,col2,text)
->setColumn('A:A', 20);
Multi-sheet workbooks. Call addSheet() for each sheet; each returns a fresh handle, and writes go to the most recently added sheet. If you need to interleave, keep getHandle() output per sheet and be aware the extension is stateful and single-cursor.
path is a directory. Passing storage_path('app/x.xlsx') as path produces a file literally named x.xlsx inside a non-existent directory, or fails silently. Split directory and filename.fileName() and write*() rarely throw; the library returns the string path or a resource. Always file_exists() the result of output() before you serve it, and log if not.openspout/openspout (streaming) or PhpSpreadsheet. Don't try to force xlswriter into a read-modify-write workflow.insertFormula() writes the formula string; Excel computes on open. If your pipeline later reads the value back without Excel opening it, pass the cached value as the second argument or you get 0/blank.insertDate() takes a timestamp and requires a format object with a date number format. Write '2026-01-01' as a string and it lands as text, un-sortable.0. Off-by-one is the most common bug you'll hit.getHandle() vs fluent return. Mixing ->getHandle() storage with fluent chaining breaks the builder — pick one style per method chain. The handle is what writeRow()/write() need.Excel(['path' => ..., 'memory' => true]) keeps the workbook in RAM (fast, but O(file size)). Leave it off for large exports; the default spills to disk in the configured path.openFile() before output() if you wrote via the handle path — calling output() on a closed/abandoned builder writes a truncated file with no error. Prefer close() for explicit finalization in long-running workers.path directory is where temp files land; in a queue worker, use a per-job unique directory (storage_path('app/tmp/'.$jobId)) and clean it up, or parallel jobs will collide on temp filenames.php:8.x-cli images. Add pecl install xlswriter in your Dockerfile and pin the version — the "last release" date being ahead of normal implies very active/rolling releases; a version bump can change format behavior.Vtiful\Kernel\Format options; conditional formatting and data validation are exposed as methods on the sheet handle. If you need a feature that isn't wired up, file an issue — you cannot polyfill inside PHP because the logic is in C.How can I help you explore Laravel packages today?