简介:面向PHP开发者的Excel导出示例资源包,聚焦通过PHPExcel或PhpSpreadsheet创建并保存Excel文件这一常见需求。压缩包共2个文件,包含1个PHP脚本和1张JPG效果截图,整体约30KB。脚本演示了从引入类库、实例化工作簿对象,到设置单元格值、调整字体对齐等样式,再到最终保存文件的完整流程;截图直观呈现生成后的Excel表格外观,便于对照验证代码输出。资源可应用于电商商品批量导出、管理后台数据下载、CRM报表生成等场景,也可帮助刚接触Excel导出功能的读者快速理解两个主流PHP类库的基本用法。代码量小、结构清晰,适合直接套用或按业务字段调整,并在此基础上扩展多工作表、公式、图表等复杂功能。目前已有154人学习,对于想快速上手PHP生成Excel的开发者是一份轻量实用的参考。
1. PHP 生成 Excel 文件:不是改个后缀名就完事,选型决定成败
某个管理后台要加“导出报表”功能,第一版图省事,直接拼了个 HTML 表格,把文件后缀改成 .xls 就丢给用户。结果 Excel 打开时弹“文件格式与扩展名不匹配”,WPS 里表格样式全乱。你确实是在用 PHP 生成 excel 文件,但生成的不是真正的 Excel 文件。后来换了开源的电子表格组件,用 OOXML 协议写 .xlsx,问题才彻底消失。这篇笔记就把我当时从选型到落地的过程复盘一遍,适合正在做报表导出、后台管理系统的 PHP 开发者。源码和工具类资源我会拆开讲,重点落在哪些参数值得改、哪些坑必须躲。
2. 从零跑通 xlsx 生成:依赖、最小示例与两种输出方式
2.1 选型逻辑:为什么 CSV 和 HTML 伪装都不够用
先说结论:如果你只需要纯数据、不要求格式,CSV 是最轻的方案,但它有三个硬伤。第一,CSV 没有真正的工作表概念,多 Sheet 和冻结窗格都不支持;第二,CSV 默认用逗号分隔,字段里如果带逗号、换行和引号,解析容易出现错位;第三,CSV 文件被 Excel 打开时,中文编码要手动选,很多用户根本不会操作。HTML 表格伪装成 .xls 更坑,它本质是文本文件,Excel 每次打开都会弹格式警告,而且公式、数据透视表这类功能完全没法用。
所以我的选型判断是:只要导出场景里出现“表头要加粗”“日期要格式化”“要冻结首行”任何一项,就直接上电子表格组件。这里要提醒一个历史遗留问题:老牌的 PHPExcel 已经在 2019 年前后停止维护,PHP 8 环境下用它会报类方法弃用警告,新项目不要再用。我选的是它的继任者 PhpSpreadsheet,命名空间从 PHPExcel 改成了 PhpOffice\PhpSpreadsheet,API 整体相似,迁移成本不算高。
下面这张表是我当时对比三个方案的结论:
| 方案 | 能交付什么 | 主要短板 |
|---|---|---|
| CSV | 纯数据表格 | 编码需要处理、不支持多 Sheet、格式能力为零 |
| HTML 伪装 .xls | 带简单样式 | Excel 打开必弹警告、不是二进制 xls |
| PhpSpreadsheet | 真正的 .xlsx 文件 | 依赖 composer、大数据量时要调内存 |
工具包里最常用的命令就是下面这行,装完直接引入自动加载文件就能用。
composer require phpoffice/phpspreadsheet安装时注意看 composer 输出的 PHP 版本要求,新版本一般要求 PHP 8.0 以上。如果服务器还是 PHP 7.4 老环境,装完大概率会报平台依赖错误,常见做法是指定兼容老 PHP 的版本分支。
2.2 最小可用代码:创建一个带数据的 .xlsx 文件
装好依赖之后,第一件事是先跑通一个最小示例。不要一上来就写完整导出类,先把“生成文件”这个链路走通,后面再往上加功能。下面这段代码我放在模拟项目 X 的 public 目录下测试,直接浏览器访问就能下载文件。
<?php require 'vendor/autoload.php'; use PhpOffice\PhpSpreadsheet\Spreadsheet; use PhpOffice\PhpSpreadsheet\Writer\Xlsx; // 创建新的电子表格对象 $spreadsheet = new Spreadsheet(); // 获取当前活动工作表 $sheet = $spreadsheet->getActiveSheet(); // 给单元格赋值 $sheet->setCellValue('A1', '订单号'); $sheet->setCellValue('B1', '客户名称'); $sheet->setCellValue('C1', '金额'); $sheet->setCellValue('A2', 'SO-1001'); $sheet->setCellValue('B2', '某公司'); $sheet->setCellValue('C2', 1280.50); // 设置工作表标题 $sheet->setTitle('订单列表'); // 创建写入器并输出到浏览器 $writer = new Xlsx($spreadsheet); $filename = urlencode('订单导出.xlsx'); header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); header('Content-Disposition: attachment;filename="' . $filename . '"'); header('Cache-Control: max-age=0'); $writer->save('php://output'); exit;这段代码的核心逻辑是三步:new Spreadsheet() 创建对象、setCellValue() 填数据、Xlsx writer 把对象序列化成 .xlsx 二进制文件。这里有两个参数值得说明。第一个是 header 里的 Content-Type,必须写成 application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,这是 .xlsx 文件的标准 MIME 类型,写错成 application/octet-stream 也能下载,但部分浏览器会提示文件类型不安全。第二个是 Content-Disposition 里的 filename,中文文件名必须用 urlencode 转码,否则不同浏览器表现不一致,后面避坑章节会细讲。
实际跑通后你会发现,setCellValue 的第二个参数不区分字符串和数字,PhpSpreadsheet 会按 PHP 变量的实际类型推断单元格类型。这里有个容易忽略的点:如果传入的是字符串形式的 '1280.50',Excel 里就是文本;如果传入数字 1280.50,Excel 里就是数值,可以参与求和计算。导出报表前,先确认数据源里金额字段是不是已经被转成了字符串。
2.3 文件落地与浏览器下载两种输出方式
生产环境里不一定每次都要直接下载,有时候要把文件存到服务器,等用户点“已生成的文件列表”再下载。这种场景下,save() 的参数从 php://output 换成服务器路径即可。
$writer = new Xlsx($spreadsheet); $savePath = '/data/export/orders_20250101.xlsx'; $writer->save($savePath);建议把文件放到 Web 可访问目录之外,通过临时链接或 readfile() 输出,避免文件被搜索引擎收录。输出时记得设置 Content-Length,这样下载进度条能正常显示。
header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); header('Content-Disposition: attachment;filename="orders_20250101.xlsx"'); header('Content-Length: ' . filesize($savePath)); readfile($savePath);这两种方式我都实际用过,浏览器直出适合小文件,落地保存适合生成报告后异步通知用户下载。如果你做的是定时任务导出,建议走第二种,可以有效避免 PHP 执行超时导致文件写到一半断掉。
3. 把数据写进工作表:类型控制与格式增强的实用组合
3.1 从数组批量写入:fromArray 减少循环调用
最小示例里一个个 setCellValue 适合调试,真正导入几千行数据时,逐行循环的效率很低。PhpSpreadsheet 提供了 fromArray 方法,可以把二维数组一次性写入工作表。
$rows = [ ['SO-1001', '某公司', 1280.50], ['SO-1002', '某实验室', 2300.00], ['SO-1003', '某开发者', 860.00], ]; $sheet->fromArray($rows, null, 'A2');这里的第一个参数是二维数组,每个子数组对应一行;第二个参数是空值处理规则,传 null 表示不特殊处理空值;第三个参数 A2 表示从 A2 单元格开始写入。我一般会先单独写表头,再调用 fromArray 写数据,这样表头样式可以单独控制。需要说明的是,fromArray 只能写连续的矩形区域,如果数据里有合并单元格需求,还是要手动处理。
3.2 单元格类型:数字、日期与公式,最容易出问题的三个类型
我最初写导出时,日期直接 setCellValue('A2', '2025-01-01'),Excel 里显示是文本,不能用日期筛选。正确做法是把 PHP 时间戳转成 Excel 日期序列号,再设置单元格的数字格式为日期格式。这一步属于踩坑重灾区,后面避坑章会提到。
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate; use PhpOffice\PhpSpreadsheet\Style\NumberFormat; $timestamp = strtotime('2025-01-01 10:30:00'); $sheet->setCellValue('D2', ExcelDate::PHPToExcel($timestamp)); $sheet->getStyle('D2')->getNumberFormat()->setFormatCode(NumberFormat::FORMAT_DATE_DATETIME);EXCEL 的日期序列号从 1900 年 1 月 1 日开始计算,PHP 的时间戳是从 1970 年开始,直接混用肯定会出错。ExcelDate::PHPToExcel 方法内部已经做了换算,不要自己手动减天数。日期格式这里,FORMAT_DATE_DATETIME 对应 yyyy-mm-dd hh:mm:ss,如果只想显示年月日,就改成 FORMAT_DATE_YYYYMMDD2。
公式写入相对简单,字符串以等号开头即可。
$sheet->setCellValue('E2', '=C2 * 0.06');但这里有个前提:PhpSpreadsheet 默认不会真的计算公式结果,它只是把公式字符串写入文件。Excel 打开时才会计算,如果你用读回脚本检查单元格值,读到的可能还是公式字符串,而不是计算结果。要拿到计算结果,需要用 getCalculatedValue() 方法,或者配置 PreCalculateFormulas 为 true,后面避坑章节再展开。
数字类型里还有一个坑是超长数字,比如身份证号、银行卡号。Excel 的数字精度只有 15 位有效数字,超过后会自动转成科学计数法,而且后几位会被抹成 0。解决办法是显式声明为字符串类型。
use PhpOffice\PhpSpreadsheet\Cell\DataType; $sheet->setCellValueExplicit('F2', '110101199001011234', DataType::TYPE_STRING);setCellValueExplicit 的第三个参数是数据类型常量,TYPE_STRING 强制按文本保存。这个方法的语义是“相信我,这就是字符串”,它会跳过自动类型判断,所以年月日这种看起来像数字的值也会保存为文本。
3.3 格式增强:表头加粗、列宽、冻结窗格与自动筛选
数据填完后,不加任何样式的 xlsx 能看,但不好用。我一般会给表头加粗、给列设置宽度、冻结首行、开启自动筛选,这四件套做完,报表的可用性会提升一大截。
use PhpOffice\PhpSpreadsheet\Style\Fill; // 表头加粗,背景色填充 $headerStyle = $sheet->getStyle('A1:F1'); $headerStyle->getFont()->setBold(true); $headerStyle->getFill() ->setFillType(Fill::FILL_SOLID) ->getStartColor() ->setARGB('FFDDEBF7'); // 设置列宽 $sheet->getColumnDimension('A')->setWidth(12); $sheet->getColumnDimension('B')->setWidth(20); $sheet->getColumnDimension('C')->setWidth(15); // 冻结第二行,滚动时表头始终可见 $sheet->freezePane('A2'); // 开启自动筛选 $sheet->setAutoFilter('A1:F' . $sheet->getHighestRow());这里的 getStyle() 返回的是样式对象,setBold(true) 是加粗,Fill::FILL_SOLID 表示纯色填充,setARGB 里的 FF 是透明度前缀,后面六位是 RGB 色值。getColumnDimension('A')->setWidth() 设置的是列宽字符数,如果列里有超长文本,可以设置 setAutoSize(true) 让 Excel 自动调整,但这个操作会影响生成性能,几百列以内可以接受。
冻结窗格 freezePane('A2') 的参数是“冻结线右下角第一个可见单元格”,A2 表示第一行被冻结,B2 表示第一行和第一列同时冻结。自动筛选的范围参数必须是连续的单元格区域,最后一个行号用 getHighestRow() 动态取,避免写死行数导致数据行变更后筛选范围不对。
这里有个节奏上的建议:先写数据,再统一做格式,最后再设置筛选和冻结,不要每写一行数据就调一次 getStyle(),那样性能很差。
4. 大数据量导出:内存、超时与分块文件的实战取舍
4.1 内存为什么会爆:单元格对象模型与缓存策略
几千行的数据导出,直接 new Spreadsheet() 完全没问题,但到了五万行以上,服务器内存就开始吃紧。原因在于 PhpSpreadsheet 的内存模型:工作簿里的每个单元格都是一个对象,包含值、样式索引、数据类型等属性。五万行乘以二十列就是一百万个单元格对象,每个对象哪怕只占几百字节,百万级就是几百兆。
针对这个场景,常用的方案是开启单元格缓存,旧版 PHPExcel 支持把单元格对象序列化后存到磁盘,PhpSpreadsheet 新版本改用 PSR-16 缓存接口。用起来很简单,但不同版本配置方式有差异,老版本的写法如下。
$cacheSettings = [ 'cacheMethod' => 'cache_to_file', 'cacheDir' => sys_get_temp_dir() . '/spreadsheet_cache', ];新版本推荐使用实现了 SimpleCache 接口的缓存类,比如 Symfony Cache 的文件适配器。如果你的数据源查询本身就慢,一次 SELECT 查十万行会把数据库连接拖死,更务实的做法是分页查询加分块写入。
4.2 分块写入与临时文件清理
我处理过最大的一次导出是十六万行流水数据,直接一次写入扛不住,最后采用的是分块生成多个 xlsx,再压缩打包的方案。核心思路是:每次只查一部分数据,生成一个小文件,用 disconnectWorksheets() 释放内存,最后再用 ZipArchive 合并。
$batchSize = 5000; $totalRows = 165000; $tmpDir = sys_get_temp_dir() . '/export_' . date('YmdHis'); mkdir($tmpDir, 0755, true); for ($offset = 0; $offset < $totalRows; $offset += $batchSize) { // 分页查询,$offset 和 $batchSize 是查询条件 $rows = $db->fetchRows($offset, $batchSize); $spreadsheet = new Spreadsheet(); $sheet = $spreadsheet->getActiveSheet(); // 每个分片都要写表头 $sheet->fromArray($header, null, 'A1'); $sheet->fromArray($rows, null, 'A2'); $writer = new Xlsx($spreadsheet); $writer->save($tmpDir . '/part_' . ($offset / $batchSize) . '.xlsx'); // 释放当前工作簿,否则内存会被持续占用 $spreadsheet->disconnectWorksheets(); unset($spreadsheet, $writer); }这里的 fetchRows 是我模拟的分页查询方法,实际使用时对应 SQL 里的 LIMIT $offset, $batchSize,或者使用游标方式。每个分片生成后立即保存,然后 disconnectWorksheets() 断开内部对象引用,这一步不能漏,漏了的话循环结束内存还是会被撑爆。
分片文件生成完后,用 ZipArchive 打包成一个压缩包再输出。
$zip = new ZipArchive(); $zipFile = $tmpDir . '/all.zip'; $zip->open($zipFile, ZipArchive::CREATE); foreach (glob($tmpDir . '/part_*.xlsx') as $file) { $zip->addFile($file, basename($file)); } $zip->close(); header('Content-Type: application/zip'); header('Content-Disposition: attachment;filename="export_all.zip"'); readfile($zipFile);glob 取到的每个分片文件都要加到压缩包里,addFile 的第二个参数是压缩包内文件名,这里用 basename 保留了 part_0.xlsx 这样的命名,用户解压后能看出文件顺序。ZipArchive 需要 PHP zip 扩展,如果服务器没有装,可以改为逐个分片输出下载链接,但体验会差一些。打包临时目录最后要清理,我一般放在 try/finally 块里用递归删除函数处理,避免磁盘被零散文件堆满。
4.3 规避超时与前端体验:从长期任务的角度思考
大数据导出不只是内存问题,PHP 默认执行时间上限是 30 秒,十六万行的数据,即使内存扛住了,生成时间也可能超过这个数。常见做法是 set_time_limit(0) 取消时间限制,但这会让脚本变成一个长期任务,用户在浏览器里干等,体验很差。
更成熟的方案是改成异步任务:前端发起导出请求,后台生成文件到磁盘,生成完成后在前端显示下载链接。这个思路比 set_time_limit(0) 靠谱得多,因为即使前端断开连接,后台任务也能继续跑。实现时可以用消息队列或者数据库任务表,代码层面只是把 save() 的文件路径存下来,让前端轮询这个状态。
如果你不想引入消息队列,临时方案也可以,设置足够大的内存和执行时间,然后同步下载。我一般会在导出页面加一行提示“最多可能需要两分钟,请勿关闭页面”,虽然笨,但能挡住大部分用户的重试操作。真正要优化到毫秒级,就得从业务侧下手,比如导出范围限制在一万行以内,超过的直接拒绝。
5. 避坑与排查:乱码、文件损坏与科学计数法的五个记录
5.1 中文文件名变成下划线或乱码
现象:文件名里带“订单导出”四个字,下载时变成“_”或者一串百分号编码,有些浏览器直接报错。
原因:Content-Disposition 头里的 filename 参数只支持 ASCII 字符,直接拼中文进去会被浏览器按 Latin-1 解析,中文就乱了。urlencode 只在某些浏览器有效,不是通用解。
解决:用 urlencode 转码,同时加上 filename* 参数指定 UTF-8 编码。
$fileName = '订单导出.xlsx'; header('Content-Disposition: attachment;filename="' . urlencode($fileName) . '";filename*=UTF-8\'\'' . rawurlencode($fileName));这样新浏览器读 filename*,老浏览器退回 filename 的 ASCII 版本,兼容性最好。我从那以后写所有下载接口都统一用这个格式。
5.2 打开文件提示“文件格式或扩展名无效”
现象:文件下载成功,但 Excel 一打开就弹“文件格式或扩展名无效”,确定后里面是乱码或空白。
原因:绝大多数情况是生成文件前有额外的输出污染了缓冲区。比如 save('php://output') 之前,PHP 脚本里有一个空格、一条错误警告、或者某个文件尾部的 ?> 后带了一个换行,这些字符会被写入到 xlsx 文件的头部,导致文件结构损坏。
解决:在 save 之前清空输出缓冲区。
ob_end_clean(); $writer->save('php://output'); exit;注意 ob_end_clean() 会丢弃之前所有缓冲区内容,如果框架本身依赖缓冲区做模板渲染,要谨慎调用。另外排查时先用编辑器打开生成的 xlsx 文件,看文件头是不是 PK 开头的 ZIP 签名,如果是 PK 就说明结构正常,问题出在前置输出;如果不是,检查项目里有没有文件在 PHP 标签外留了空白行。
5.3 身份证号变成科学计数法且后几位变成 0
现象:导出用户表里的身份证号,Excel 里显示成 1.10101E+17,点进去看还是被截断成 110101199001011230。
原因:Excel 对数字类型最多保留 15 位有效数字,超过的部分会被四舍五入为 0。这是 Excel 本身的精度限制,不是文件生成代码的问题。
解决:写单元格时显式声明为字符串类型。
$sheet->setCellValueExplicit('A2', $idCardNumber, DataType::TYPE_STRING);如果数据是从数据库查出来的,查询时要把字段 CAST 成字符串,否则有些数据库驱动会直接返回 int,转成 PHP int 后精度已经丢了,写到 Excel 里也是错的。
5.4 读回的日期比源数据少一天
现象:数据库里存的是 2025-01-01,导出到 Excel 后显示 2024-12-31,或者时间差了几分钟。
原因:Excel 的日期系统从 1900 年 1 月 1 日算起,PHP 的 time() 是基于 Unix 时间戳,两套系统之间换算要考虑闰年差异,PhpSpreadsheet 的 Date::PHPToExcel 已经处理了这部分。出现偏差,多数是 PHP 时区和数据库时区不一致,时间戳本身就已经错了。
解决:在导出脚本入口统一时区。
date_default_timezone_set('Asia/Shanghai');然后在写入前用 Date::PHPToExcel 转换时间戳,不要直接把日期字符串塞给 setCellValue。如果时间戳对,日期格式也设置了还是差一天,检查有没有在中间层手动减过 86400 秒,这种“修正”在跨时区环境里就是玄学,删掉即可。
5.5 导出几万行后 CPU 飙高、文件接近 100MB
现象:五万行的订单数据,导出过程持续十几秒,生成的文件有九十多兆,下载也慢。
原因:每个单元格对象携带样式信息,如果你用 getStyle() 给每一行都设置一遍边框或颜色,文件体积会膨胀得很快。另外高亮背景填充如果按行设置,样式数量就是行数乘以列数,文件想不变大都难。
解决:样式只用于表头和关键列,不要对数据区逐行设置。数据区域需要用边框线的话,一次设置整个区域,而不是循环每一行。
$sheet->getStyle('A1:F50000') ->getBorders() ->getAllBorders() ->setBorderStyle(Border::BORDER_THIN);这段代码虽然也会生成五万个单元格的样式对象,但 PhpSpreadsheet 内部会做样式去重,比逐行设置要省很多。如果文件还是过大,考虑把明细数据拆到多个 Sheet,分表存放,或者直接转 CSV 让用户用 Excel 打开后自行另存为 xlsx。
6. 用读回脚本给导出文件做体检:顺手且省心的自检技巧
6.1 为什么导出完成不等于文件可用
导出接口跑完,文件下载下来,用户说打不开,你在服务器上检查文件大小没问题,磁盘空间也够,问题出在哪?最直接的办法是不要只看文件是否存在,用 PhpSpreadsheet 自带的读取器把文件读回来,看工作表数量、行列总数、抽查几个关键单元格的值。这一步相当于给导出文件做一次自动化体检,能发现表头错位、值类型不对、公式没有计算结果这一类隐藏问题。
6.2 最小读回验证脚本
我一般会写一个简单的读回脚本,放在 tests 目录下,每次导出逻辑改动后跑一遍。
<?php require 'vendor/autoload.php'; use PhpOffice\PhpSpreadsheet\IOFactory; $filePath = $argv[1]; $reader = IOFactory::createReaderForFile($filePath); // 只读取数据,不读样式,加快速度 $reader->setReadDataOnly(true); $spreadsheet = $reader->load($filePath); $sheet = $spreadsheet->getActiveSheet(); // 输出基本体检信息 echo '工作表数量: ' . $spreadsheet->getSheetCount() . PHP_EOL; echo '标题: ' . $sheet->getTitle() . PHP_EOL; $range = $sheet->getHighestRowAndColumn(); echo '行数: ' . $range['row'] . ', 列数: ' . $range['column'] . PHP_EOL; // 抽查首行和末行数据 $firstRow = $sheet->getRowIterator(1, 1)->current(); echo '表头: ' . implode(',', iterator_to_array($firstRow->getCellIterator())) . PHP_EOL;这里 IOFactory::createReaderForFile 会根据文件扩展名自动选择对应的读取器,不用手动指定。setReadDataOnly(true) 的目的是跳过样式解析,文件如果非常大,这个设置能显著降低读取时的内存占用。getHighestRowAndColumn() 返回的是一个关联数组,row 和 column 分别是最大行号和最大列字母,正好可以拿来做断言行数是否正确的依据。
对于关键数据,我会在脚本里直接做断言。
$expectedHeader = ['订单号', '客户名称', '金额']; $actualHeader = []; $cellIterator = $sheet->getRowIterator(1, 1)->current()->getCellIterator(); foreach ($cellIterator as $cell) { $actualHeader[] = $cell->getValue(); } if ($actualHeader !== $expectedHeader) { echo "表头不匹配,实际: " . implode(',', $actualHeader) . PHP_EOL; exit(1); }这段代码通过 getRowIterator 拿到第一行的所有单元格,逐列取出值组成数组,再和期望表头做严格对比。如果表头里有公式、空格之类的问题,会直接在这里暴露。这比人工打开 Excel 看一遍要快得多,而且可以接进持续集成流程,每次发布导出模块前自动跑一遍。
6.3 把读回变成例行动作
在那以后,我每种导出接口写完后,都会强制走一遍这个读回流程,重点验证三件事:表头几个字段没有错位、行数等于查询结果数、日期字段没有差一天。特别是改过列映射和日期格式之后,读回脚本能在五秒内告诉我文件有没有问题,而不是等用户来投诉。生成文件和读取文件用的是同一个库,虽然不能覆盖 Excel 本身的渲染问题,但最少能拦住一半以上的主动 bug。希望这个自检思路对你也有用。
本文还有配套的精品资源,点击获取