Excel output
StaticPHP\Presentation\Models\Tables\Output\Excel is the second implementation of
OutputInterface. It walks the same
Table the HTML generator does and writes an
.xlsx file instead of markup.
The dependency
Section titled “The dependency”The file opens with three imports from a library the package does not require:
<?php
use PhpOffice\PhpSpreadsheet\Spreadsheet;use PhpOffice\PhpSpreadsheet\Cell\DataType;use PhpOffice\PhpSpreadsheet\IOFactory;phpoffice/phpspreadsheet is declared in composer.json, but twice over and in neither
place as a hard dependency - once in require-dev, once in suggest:
"require-dev": { "phpoffice/phpspreadsheet": "^3.0 || ^4.0", "phpstan/phpstan": "^2.2", "phpstan/phpstan-phpunit": "^2.0", "phpunit/phpunit": "^13.0", "squizlabs/php_codesniffer": "^4.0", "twig/twig": "^3.0"},"suggest": { "phpoffice/phpspreadsheet": "Required by Presentation\\Models\\Tables\\Output\\Excel. Nothing else in the package touches it, so a table that only renders html does not need it."}What each of those means for an application that depends on this package:
require-devis the package’s own development environment. Composer ignores a dependency’srequire-dev, so requiring4apps/staticphp-corenever pulls phpspreadsheet in. It is declared there so this class can be exercised by the package’s own test suite -tests/Presentation/Models/Tables/ExcelOutputTest.php- and so CI installs it.suggestis advisory. Composer prints it after an install andcomposer suggestlists it; it installs nothing and constrains nothing.
require is untouched by either, and is still php plus three extensions:
"require": { "php": ">=8.4 <9", "ext-intl": "*", "ext-mbstring": "*", "ext-pdo": "*"}So an application that wants an .xlsx export requires the library itself:
composer require phpoffice/phpspreadsheetNothing else in src/ references it, so an installation without it runs perfectly well and
only this one class is unusable - the failure is a fatal “class not found” the moment
makeOutput() is reached, not a degraded fallback.
Its own extension requirements are its to declare; consult its documentation rather than
this page. This package’s CI workflow installs intl, mbstring, xml, zip, gd, bcmath and
the PDO drivers, and gd is there on phpspreadsheet’s account so that the Excel output test
can run.
Settings
Section titled “Settings”| Property | Type | Default |
|---|---|---|
$filename |
string |
'' |
$author |
string |
'' |
$formatter |
?\Closure |
null |
$filename is used without the extension - showOutput() appends .xlsx. $author goes
into the workbook properties as both creator and last modified by. $formatter is called
with the Spreadsheet object after the data is written and before it is saved, which is the
only place to set column widths, styles or freeze panes.
<?php
use StaticPHP\Presentation\Models\Tables\Output\Excel;
$output = new Excel($table);$output->filename = 'users-2026-08';$output->author = 'Reporting';$output->formatter = function ($xls) { $sheet = $xls->getActiveSheet(); $sheet->getStyle('A1:D1')->getFont()->setBold(true); $sheet->freezePane('A2');};
$table->outputGenerator = $output;$table->showOutput();makeOutput()
Section titled “makeOutput()”Returns a Spreadsheet rather than a string - the signature is makeOutput(): Spreadsheet.
OutputInterface::makeOutput() is declared : mixed, which both generators narrow: Html
to string, Excel to Spreadsheet.
It writes to sheet index 0. Row 1 is the header, taken from each column’s $title; data
starts at row 2. A column is skipped when its $exportKey is exactly false or its
$type is ColumnType::ROW_NUMBER.
The value for a cell comes from $exportKey, falling back to $dataKey when $exportKey
is null:
<?php
$exportKey = $column->exportKey;if ($column->exportKey === null) { $exportKey = $column->dataKey;}
$cellValue = ( is_callable($exportKey) ? $exportKey($column, $rowIndex, $rowItem) : ($rowItem[(string) $exportKey] ?? null));Note what this path does not do:
- It ignores
$dataFormatterentirely. The HTML generator’s number and date formatting does not apply; raw row values reach the sheet. Format in the closure, or in$exportKey. - It ignores
$showColumn. A column hidden in the HTML table is still exported. - It does not tell a missing key from a null one. The lookup is
?? null, so a key that is not in the row is silentlynulland lands as0in a numeric column and''in every other. Both$exportKeyand$dataKeydefault tonull, so a column with neither set looks the row up under the key''and exports empty cells rather than announcing itself.
Closure $exportKeys get three arguments - ($column, $rowIndex, $rowItem) - one fewer than
the four the HTML generator passes to $dataKey.
Cell types
Section titled “Cell types”Every cell is written with setValueExplicit() under a switch on the column type:
<?php
switch ($column->type) { case ColumnType::INT: case ColumnType::DECIMAL: $cell->setValueExplicit($cellValue ?? 0, DataType::TYPE_NUMERIC); break;
default: $cell->setValueExplicit($cellValue ?? '', DataType::TYPE_STRING); break;}The cases are ColumnType cases rather than their backing strings, and that matters:
switch compares with ==, and a backed enum is never loosely equal to its own value, so
case 'int': would match nothing and send every number to the string branch. The two
numeric types are INT and DECIMAL - there is no FLOAT case on the enum.
Everything else - text, dates, booleans, and every other
ColumnType - is written as
TYPE_STRING. A null becomes 0 in a numeric column and '' everywhere else.
A real run over three columns and two rows, the second one carrying null in both numeric
cells and an ordinary string in the text cell:
phpoffice/phpspreadsheet 3.10.7
cell column type cell data type valueA1 header DataType::TYPE_STRING 'Name'B1 header DataType::TYPE_STRING 'Qty'C1 header DataType::TYPE_STRING 'Total'A2 TEXT DataType::TYPE_STRING 'Widget'B2 INT DataType::TYPE_NUMERIC 3C2 DECIMAL DataType::TYPE_NUMERIC 10.5A3 TEXT DataType::TYPE_STRING 'Gadget'B3 INT DataType::TYPE_NUMERIC 0C3 DECIMAL DataType::TYPE_NUMERIC 0Row 1 is the header, written with setValue() rather than setValueExplicit(), so
phpspreadsheet infers its type instead of being told one.
testNumericColumnsAreWrittenAsNumbersRatherThanText() in
tests/Presentation/Models/Tables/ExcelOutputTest.php is the regression test for the
numeric branch, so a workbook whose figures arrive as unsummable text is a test failure
rather than something to discover in a spreadsheet.
showOutput()
Section titled “showOutput()”Builds the spreadsheet, sets the author properties, runs $formatter, sends the download
headers and streams the file to php://output through the Xlsx writer with
setPreCalculateFormulas(false).
The headers sent, in order:
Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheetContent-Disposition: attachment;filename="{filename}.xlsx"Cache-Control: max-age=0Cache-Control: max-age=1Expires: Mon, 26 Jul 1997 05:00:00 GMTLast-Modified: {now} GMTCache-Control: cache, must-revalidatePragma: publicCache-Control is sent three times and header() replaces by default, so only
cache, must-revalidate survives; the two max-age lines are dead. $filename is
interpolated into the Content-Disposition header unescaped - keep it to values you
generate, never a request parameter, or a newline in it splits the response.
There is no ob_end_clean() before the write, so anything already echoed - a stray blank
line after a ?>, a warning or a var_dump() left in a $exportKey closure - lands in the
file and corrupts the archive.
A download route
Section titled “A download route”The generator needs the same table the HTML view uses, minus the pagination, since an export is normally the whole result set:
<?php
public static function export($filterData = '', $sortData = ''){ $table = new SQLTable(self::columns(), '/admin/users/'); $table->initData($filterData, $sortData, null); $table->sqlFilter->prepareQueries();
$sql = 'SELECT * FROM users u' . $table->sqlFilter->querySql() . $table->sqlSort->sortQuery(); $rows = Db::fetchAll($sql, $table->sqlFilter->params()); $table->setRows($rows);
$output = new Excel($table); $output->filename = 'users'; $table->outputGenerator = $output; $table->showOutput();}Passing null as the third argument of initData() skips the Pagination and
SQLPagination objects, which the Excel generator never touches. It reads the rows through
getRows(), so they must have been set.