简介
面向在网站后台需要向用户提供数据下载服务的 PHP 开发人员,这份资源是一份可直接运行的 Excel 文件生成示例包,用于解决将数据库查询结果或业务数据快速导出为表格文件的常见需求。压缩包体积很小,仅 30KB,共包含一个 PHP 脚本和一张效果截图,前者是核心示例代码,后者方便开发者在没有办公软件的环境中预览输出效果。脚本内容遵循清晰的流程:先创建一个空白工作簿,再添加工作表并逐行填入数据,接着对单元格设置字体、对齐、边框等常用格式,最后保存为 Excel 文件;代码中保留了细致注释,必要时还可在此基础上扩展生成图表、多工作表或复杂公式。这份源码与工具适合电商网站做订单明细导出、信息管理系统做统计报表下载、企业 CRM 做客户数据推送等场景,开发者只要有 PHP 基础语法知识就能快速迁移到自己的项目中。资源上线至今已有 153 人浏览学习,是一份小而精的实战型入门参考。 接到“用PHP生成excel文件”这种需求,很多人第一反应是“随便百度一段代码就行”。可真到了线上,你会遇到中文乱码、文件打不开、内存直接爆炸、下载超时等一连串问题。这篇文章我把自己这些年做导出功能的选型思路、完整代码和排错过程掰开揉碎讲一遍,适合刚接触PHP导出、或者已经写了几个版本但总觉得不稳的同学看。
先说清楚一个现实:PHP生成Excel文件这件事,难点从来不在“写几个单元格”,而在数据量、文件格式、运行环境三者之间的取舍。你选的方案不对,后面所有代码都是给下一个坑铺路。
1. 先明确需求:CSV糊弄事和真正生成xlsx是两条路
1.1 三种常见的导出需求,分别对应不同方案
我在项目里见到的导出需求,基本上逃不出以下三种形态。
场景A:后台管理员的简单清单,比如导出最近一周的订单记录,数据量就几百行,没有复杂样式,能打开能筛选就够。这种需求用CSV就够了,没必要上重型库。CSV本质是纯文本文件,扩展名改成.csv,Excel双击就能打开,写起来也就几行代码。
<?php $filename = 'orders_' . date('Ymd') . '.csv';
header('Content-Type: text/csv; charset=UTF-8');
header('Content-Disposition: attachment; filename="' . $filename . '"');
$output = fopen('php://output', 'w');
// 输出 UTF-8 BOM,否则 Excel 打开中文 CSV 会乱码
fwrite($output, "xEFxBBxBF");
fputcsv($output, ['订单号', '客户名称', '金额', '创建时间']);
foreach ($orders as $row) {
fputcsv($output, [$row['order_no'], $row['customer_name'], $row['amount'], $row['created_at']]);
}
fclose($output);
exit;
场景B:正经的业务报表,客户要拿去汇报用的,需要固定表头、加粗、背景色、合并单元格、自动筛选,甚至表格里要有计算公式。这种就必须走真正的xlsx文件生成方案,也就是用PhpSpreadsheet这类类库。
场景C:几十万甚至上百万行的数据导出。这里我劝你先冷静一下,Excel单个工作表上限是1048576行,几十万行塞进去,Excel打开本身就卡得不行。这种需求通常要做成异步任务,分批生成多个sheet,或者干脆导出CSV压缩包,交给数据分析同学用专业工具去跑。
1.2 为什么现在新项目都直接用PhpSpreadsheet
如果你搜过老代码,大概率会看到一大把PHPExcel的教程。这里明确说一句:PHPExcel已经停止维护了,它的GitHub仓库早就进入只读状态,PHP 7.4以上跑PHPExcel都会有一堆兼容性问题,PHP 8直接就是各种报错。官方推荐的继承者就是PhpSpreadsheet,命名空间从PHPExcel换成了PhpOfficePhpSpreadsheet,整体API风格一脉相承,但内部实现和性能做了大量优化。
Python圈子里用pandas读写Excel是标配,PHP圈对应的就是PhpSpreadsheet。它支持读、写、样式、公式、图表、数据验证,日常能想到的电子表格操作基本都覆盖了。如果你的老项目还在用PHPExcel,我的建议是尽早重写,别等线上炸了再补。
2. PhpSpreadsheet环境准备与一个最简导出Demo
2.1 Composer安装,注意PHP版本要求
PhpSpreadsheet用Composer管理依赖,安装命令就一行:
composer require phpoffice/phpspreadsheet
这里有个很容易踩的坑:PhpSpreadsheet的不同大版本,对PHP版本要求不一样。以常见的1.x版本为例,支持PHP 7.4到8.x,但到了2.0以上,最低要求是PHP 8.1。如果你还在用PHP 7.4的服务器,装2.x版本会直接报错,这时候需要用 composer require phpoffice/phpspreadsheet:^1.29 锁住大版本。
另外提醒一句,如果你用的宝塔、phpstudy这类集成环境,命令行里的PHP版本和网站配置的PHP版本可能不是同一个。平时自己在终端测试没问题,一到线上部署就各种Class not found,八成就是这个原因。装完依赖后用 php -v 和 php -m 确认一下版本和扩展。
2.2 第一个能跑的导出脚本
先写一个最小可用的例子,感受一下PhpSpreadsheet的基本操作流程。
getActiveSheet();
$sheet->setCellValue('A1', '姓名');
$sheet->setCellValue('B1', '部门');
$sheet->setCellValue('C1', '工资');
$data = [
['张三', '技术部', 15000],
['李四', '运营部', 12000],
];
$rowIndex = 2;
foreach ($data as $row) {
$sheet->setCellValue('A' . $rowIndex, $row[0]);
$sheet->setCellValue('B' . $rowIndex, $row[1]);
$sheet->setCellValue('C' . $rowIndex, $row[2]);
$rowIndex++;
}
$filename = 'demo_' . date('YmdHis') . '.xlsx';
$writer = new Xlsx($spreadsheet);
// 保存到服务器本地
$writer->save($filename);
echo "文件已生成:{$filename}n";
这个例子只做了一件事:把二维数组写到工作表里。注意 setCellValue 的用法,第一个参数是单元格坐标,第二个参数是值。坐标就是Excel用户熟悉的A1、B1这种行列表示法。
2.3 直接输出到浏览器下载,header顺序很关键
实际项目里大部分需求是点击按钮直接下载文件,而不是先存到服务器再让用户下载。输出到浏览器时要设置三个headers,顺序还不能乱。
$filename = '报表_' . date('YmdHis') . '.xlsx';
header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Cache-Control: max-age=0');
$writer = new Xlsx($spreadsheet);
$writer->save('php://output');
exit;
save('php://output') 的意思是直接写入PHP输出流,配合浏览器下载的header,就能实现点击即下载。这里有一个几乎所有新手都会踩的坑:在调用header之前,任何一点点输出都会破坏文件内容。比如PHP文件开头多了一个空格,或者include的配置文件里多了一个换行,都会导致下载下来的文件打不开。写这段代码时, header() 之前只保留 require 语句,其他什么都不要输出。
3. 让表格像报表:样式、合并、公式和日期格式的写法
3.1 表头样式:加粗、背景色、边框一次搞定
光有数据不算报表,至少得让表头看起来是那么回事。PhpSpreadsheet的样式系统基于一个核心思路:先按单元格范围选中区域,再设置样式属性。
use PhpOfficePhpSpreadsheetStyleFill;
use PhpOfficePhpSpreadsheetStyleBorder;
use PhpOfficePhpSpreadsheetStyleAlignment;
$sheet->getStyle('A1:F1')->getFont()->setBold(true);
$sheet->getStyle('A1:F1')->getFont()->getColor()->setARGB('FFFFFFFF');
$sheet->getStyle('A1:F1')->getFill()
->setFillType(Fill::FILL_SOLID)
->getStartColor()->setARGB('FF4472C4');
$sheet->getStyle('A1:F1')->getAlignment()
->setHorizontal(Alignment::HORIZONTAL_CENTER)
->setVertical(Alignment::VERTICAL_CENTER);
$sheet->getStyle('A1:F1')->getBorders()
->getAllBorders()->setBorderStyle(Border::BORDER_THIN);
样式代码看起来有点绕,但拆开就好理解: getStyle() 选定范围,然后通过 getFont() 、 getFill() 、 getAlignment() 、 getBorders() 四个入口分别控制字体、填充背景、对齐方式和边框。
3.2 列宽、行高和合并单元格
Excel里的表格,列宽和合并单元格基本是必设项。列宽设置很简单:
$sheet->getColumnDimension('A')->setWidth(12);
$sheet->getColumnDimension('B')->setWidth(20);
$sheet->getColumnDimension('C')->setWidth(15);
合并单元格用的是 mergeCells 方法。比如要在第一行放一个大标题,横跨A到F五个列:
$sheet->mergeCells('A1:F1');
$sheet->setCellValue('A1', '2024年度销售统计报表');
$sheet->getStyle('A1:F1')->getFont()->setSize(16);
$sheet->getStyle('A1:F1')->getFont()->setBold(true);
注意,合并单元格后,后续写入数据时坐标要避开被合并的区域,否则Excel会提示格式问题。
3.3 公式不是字符串,是Excel能识别的表达式
很多初学者不知道PhpSpreadsheet支持写公式,直接在代码里算出合计值,再塞进单元格。这样做在功能上没错,但少了Excel的联动能力。正确的做法是让Excel自己算,比如C列有10行数据,要求合计可以这样写:
$sheet->setCellValue('C11', '=SUM(C2:C10)');
PhpSpreadsheet不会执行这个公式计算,它只是把公式表达式写进文件。用户用Excel打开的时候,Excel会自动计算结果显示出来。这个特性在财务报表里特别实用,比如利润率、占比这些后续要调整数据的指标,保持公式联动比写死数值强太多。
3.4 日期和金额格式:数据本身没问题,显示格式也很重要
很多导出的时间字段直接用 2024-08-15 14:30:00 这种字符串塞进去,Excel打开后虽然能看,但无法按日期做筛选和分组。更好的方案是把时间转成Excel日期数字,再设置日期格式。
php
use PhpOfficePhpSpreadsheetStyleNumberFormat;
$sheet->setCellValue('D2', PhpOfficePhpSpreadsheetSharedDate::PHPToExcel(strtotime('2024-08-15 14:30:00')));
$sheet->getStyle('D2')->getNumberFormat()->setFormatCode('YYYY-MM-DD HH:MM:SS');
$sheet->setCellValue('E2', 123456.78);
$sheet->getStyle('E2')->getNumberFormat()->setFormatCode('#,##0.00');
日期字段用 Date::PHPToExcel() 把时间戳转换成Excel的日期序列值,然后通过 setFormatCode 指定显示格式。金额字段则设置千分位格式。这样导出的文件,用户可以继续用Excel的数据透 视、筛选功能,体验完全不一样。
3.5 冻结首行和自动筛选:几十行代码提升体验
最后加两个提升体验的功能。数据量一多,滚动时看不到表头是常见痛点,冻结首行就能解决。自动筛选则方便用户按条件过滤数据。
$sheet->freezePane('A2');
$sheet->setAutoFilter('A1:F100');
freezePane('A2') 的含义是:以A2单元格为基准,冻结A2上方的行和左侧的列。如果数据有30行,就把自动筛选范围设成A1:F30,范围里的下拉箭头就自动出现了。
4. 大数据量导出时,内存和性能优化的关键配置
4.1 内存为什么会爆:先理解PhpSpreadsheet的工作机制
PhpSpreadsheet不是把文件读一遍就完事,它会在内存里构建一个完整的电子表格对象模型。一个单元格对应一个对象,每个对象还带着样式、数据类型、坐标等元信息。一万行、每行20列,就是20万个单元格对象,再加上中间计算和临时变量,内存很容易就涨到几百MB。
所以我特别不建议超过两万行的数据一次性用默认配置导出。不是不能导,而是导完这台服务器基本就废了,跑在共享虚拟主机上的项目直接OOM。
4.2 三个能立竿见影的性能优化开关
第一个开关是关闭公式预计算。PhpSpreadsheet在保存文件时会尝试计算公式的值,数据量大时这个操作非常费时间和内存。如果表格里不需要预存计算结果,可以在保存前设置:
$writer = new Xlsx($spreadsheet); $writer->setPreCalculateFormulas(false);
第二个开关是不要用自动列宽。 getColumnDimension()->setAutoSize(true) 虽然方便,但自动计算列宽会对每个单元格测量文本宽度,这是巨大的性能开销。大数据量导出时,手动指定固定列宽,或者干脆不设置。
第三个开关是启用单元格缓存。PhpSpreadsheet支持把单元格对象缓存到内存压缩模式甚至文件里,减少PHP对象常驻内存的开销。比较常用的做法:
use PhpOfficePhpSpreadsheetSettings; use PhpOfficePhpSpreadsheetCacheMemoryGZip; Settings::setCache(new MemoryGZip());
MemoryGZip是简单的内存压缩缓存,还有APCu、Redis等更多选择。对小项目来说,这个配置一行代码就能把峰值内存降下来四分之一以上。
4.3 真正的大数据量方案:分批写sheet或转CSV
如果数据量超过五万行,我的建议是换思路。第一种方案是拆分成多个sheet,每个sheet放两万行以内,避免单个工作表过大。在循环中每隔两万行创建一个新sheet:
$maxRowsPerSheet = 20000;
$rowIndex = 1;
$sheetIndex = 0;
foreach ($bigData as $index => $row) {
if ($rowIndex > $maxRowsPerSheet) {
$sheetIndex++;
$spreadsheet->createSheet($sheetIndex);
$spreadsheet->setActiveSheetIndex($sheetIndex);
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('数据_' . ($sheetIndex + 1));
$rowIndex = 1;
}
$sheet->setCellValue('A' . $rowIndex, $row['field1']);
$sheet->setCellValue('B' . $rowIndex, $row['field2']);
$rowIndex++;
}
第二种方案是改走CSV路线。CSV没有单元格对象模型,就是字符串流,导出几十万行毫无压力。这也是为什么很多数据平台都提供CSV下载,而不是xlsx——数据量是硬约束。
4.4 异步和超时的配合:别让用户浏览器干等
大文件生成过程中,浏览器可能早已超时断开连接。工程化的做法是把导出任务丢进队列,后台生成完文件,再通过下载链接通知用户。PHP生态里用Redis队列、RabbitMQ,或者在简单的小项目里直接用数据库表存任务状态,都行。
如果暂时没条件做异步,至少要在导出接口里临时提高执行时间限制。但千万注意,这不是治本的办法,只是给用户和服务器都留一点缓冲时间。
5. 导出环节最常见的四个坑:我的完整排查链路
5.1 下载的文件提示“文件格式或文件扩展名无效”
这个报错出现频率极高。它的本质是:xlsx文件是一个zip压缩包,文件头部有固定的魔数。如果PHP在输出文件内容之前,不小心输出了一丁点额外字符,比如一个空格、一个BOM字节、一段PHP警告,就会污染文件头,Excel打开时直接拒绝。
我处理这类问题的排查链路是这样的,你可以照着一路查下来:
- 用Notepad++或VSCode打开下载下来的文件,用十六进制视图看文件开头几个字节,正常情况下xlsx开头应该是
50 4B 03 04,也就是PK字符。 - 如果开头是
EF BB BF,说明代码里输出了UTF-8 BOM,查一下有没有在文件顶部写了print、echo,或者include进来某个带BOM的文件。 - 如果开头是PHP错误信息,比如
Warning: xxx,去PHP错误日志里拉出对应的warning和notice,修复源问题。 - 确认
header()之前没有任何输出,包括HTML标签前的空格。有些老编辑器会把UTF-8 BOM悄悄写进文件开头,可以用IDE重新保存为无BOM格式。
修复方式:在 $writer->save('php://output'); 之前调用 ob_clean(); 清空缓冲区,并加上 exit; 确保后面不再有输出。这是最稳的做法。
ob_clean();
$writer->save('php://output');
exit;
5.2 下载下来的文件名全是乱码
文件名乱码,基本是和浏览器对 Content-Disposition 头的中文解析有关。旧式写法 filename="报表.xlsx" 在RFC 5987出现前没问题,但现在HTTP头默认字符集是ISO-8859-1,中文直接放进去就乱了。
正确做法是用 filename* 参数:
$filename = '销售报表_' . date('YmdHis') . '.xlsx';
$encoded = rawurlencode($filename);
header("Content-Disposition: attachment; filename*=UTF-8''{$encoded}");
这样设置后,Chrome、Edge、Safari都能正确显示中文文件名。如果你还在用老项目兼容老浏览器的写法,建议两个参数都写,旧的放ASCII中文,新的放编码后文件名。
5.3 PHP 8环境下的兼容性排查
现在新服务器基本都是PHP 8.x起步,如果你的代码是从老项目里拷过来的PHPExcel代码,运行时会直接报错。常见表现就是白屏或者各种 class not found 。
这种情况没有太多好调的,请直接迁移至PhpSpreadsheet。搬迁移过程中会碰到一个常见差异点:PHPExcel里很多方法名在PhpSpreadsheet中改变了大小写规范或命名空间。最直接的办法是全局搜索替换,把 PHPExcel_ 前缀换成对应的 PhpOfficePhpSpreadsheet 类名,然后逐个跑测试。
另外,如果你的环境是PHP 7.4,就要像我之前说的,用 composer require phpoffice/phpspreadsheet:^1.29 锁版本,避免Composer自动安装2.x版本导致的语法兼容错误。
5.4 超时、临时目录和权限问题
大表格导出到一半断掉,多半是PHP执行超时。排查时先确认两件事:一是接口里有没有 set_time_limit(0) 或延长执行时间的设置,二是nginx/apache自己有没有转发超时配置。注意,CLI模式下的超时设置和Web模式下的不一定一致,线上环境要以 phpinfo() 里显示的实际配置为准。
另一个被忽视的是临时目录。PhpSpreadsheet在处理写入时会用到临时文件,如果系统 sys_get_temp_dir() 指向的目录不可写,写入过程会抛异常。特别是规范较严的服务器环境,比如SELinux开启的状态下,临时目录权限不足会报 Unable to write to temporary file 。解决方法是显式设置可写的缓存目录:
Settings::setCache(new MemoryGZip()); // 或者 Log::setLocale 不相关,总之让单元格缓存不落到临时文件
更彻底的做法是在部署时检查并修正临时目录权限,确保PHP运行用户有写权限。
6. 把导出封装成通用类:项目里的工程化落地
6.1 一个足够通用的导出参数结构
项目里导出需求一个接一个,每次重写一遍样式代码不仅累,还容易出错。我的做法是抽一个简单通用的导出工具类,主要接收表头、数据和文件名,内部完成样式、列宽、下载输出,外部不用关心PhpSpreadsheet的复杂API。
'姓名', 'key' => 'name'], ...]
* @param array $rows [['name' => '张三', 'age' => 18], ...]
* @param string $filename 不含扩展名
*/
public static function download(array $headers, array $rows, string $filename)
{
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
// 写表头
$colIndex = 'A';
foreach ($headers as $header) {
$sheet->setCellValue($colIndex . '1', $header['name']);
$colIndex++;
}
// 表头样式
$headerRange = 'A1:' . $colIndex . '1';
$sheet->getStyle($headerRange)->getFont()->setBold(true);
$sheet->getStyle($headerRange)->getFont()->getColor()->setARGB('FFFFFFFF');
$sheet->getStyle($headerRange)->getFill()
->setFillType(Fill::FILL_SOLID)
->getStartColor()->setARGB('FF4472C4');
$sheet->getStyle($headerRange)->getAlignment()
->setHorizontal(Alignment::HORIZONTAL_CENTER);
// 写数据
$rowNum = 2;
foreach ($rows as $row) {
$colIndex = 'A';
foreach ($headers as $header) {
$value = $row[$header['key']] ?? '';
$sheet->setCellValue($colIndex . $rowNum, $value);
$colIndex++;
}
$rowNum++;
}
// 列宽自适应
$colIndex = 'A';
foreach ($headers as $header) {
$sheet->getColumnDimension($colIndex)->setWidth(18);
$colIndex++;
}
$writer = new Xlsx($spreadsheet);
$writer->setPreCalculateFormulas(false);
$encoded = rawurlencode($filename . '.xlsx');
header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
header("Content-Disposition: attachment; filename*=UTF-8''{$encoded}");
header('Cache-Control: max-age=0');
ob_clean();
$writer->save('php://output');
exit;
}
}
调用方式:
ExcelExport::download(
[
['name' => '姓名', 'key' => 'name'],
['name' => '部门', 'key' => 'dept'],
['name' => '工资', 'key' => 'salary'],
],
$dataList,
'员工工资表'
);
这个工具类把容易写乱的header、文件名编码、缓冲清理都固定在一个地方,后续即使要加新的样式逻辑也集中在一个文件里改。
6.2 在ThinkPHP、Laravel里怎么接入
Laravel里可以直接封装一个响应类,把生成的写入流包成StreamedResponse返回给前端,好处是不用先把文件写到磁盘再到处找下载路径:
use SymfonyComponentHttpFoundationStreamedResponse;
return (new StreamedResponse(function () use ($headers, $rows, $filename) {
ExcelExport::download($headers, $rows, $filename);
}));
ThinkPHP 6里则简单很多,直接在控制器里调用同一个工具类。只要Composer自动加载能找到这个类,不需要额外配置。
6.3 我在项目里长期坚持的三个导出约定
第一,文件名永远带日期时间,加上模块前缀,如 订单导出_20240815_1600.xlsx ,避免用户下载的文件覆盖同名文件。第二,所有导出动作要写操作日志,记录导出的时间、操作人、筛选条件,这个东西在出了数据安全问题时能救命。第三,但凡数据量可能超过一万行的,一律先做异步任务,不要裸写在同步接口里。
总结
导出功能写多了你会发现,同一个坑在不同项目里会换着花样出现:有时候是BOM导致文件打不开,有时候是PHP版本差异,有时候是服务器临时目录权限。我的习惯是遇到问题先看文件头字节、再看PHP错误日志、最后考虑性能配置,按这个顺序排查,九成问题能在几分钟内定位。
以上就是PHP生成Excel文件并导入导出的完整指南的详细内容,更多关于PHP生成Excel的资料请关注IT俱乐部其它相关文章!
