资讯动态

PHP导出Excel文件的完整实战指南:从选型到避坑

发布时间:2026/10/14 18:25:30 来源:尧图企业网站定制
简介面向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\PhpSpreadsheetAPI 整体相似迁移成本不算高。下面这张表是我当时对比三个方案的结论方案能交付什么主要短板CSV纯数据表格编码需要处理、不支持多 Sheet、格式能力为零HTML 伪装 .xls带简单样式Excel 打开必弹警告、不是二进制 xlsPhpSpreadsheet真正的 .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-age0); $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.50Excel 里就是文本如果传入数字 1280.50Excel 里就是数值可以参与求和计算。导出报表前先确认数据源里金额字段是不是已经被转成了字符串。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;filenameorders_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;filenameexport_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.10101E17点进去看还是被截断成 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。希望这个自检思路对你也有用。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑