PHP导出Excel:PhpSpreadsheet实战与大数据内存优化 简介面向PHP开发者的Excel导出示例资源解决在网站项目中以Excel格式导出数据的常见需求适用于数据报表、商品批量导出、CRM系统下载等场景尤其适合刚接触Excel导出的入门开发者。压缩包共2个文件包含1个php示例脚本和1张jpg效果示意图包体仅30KB小巧易用。示例脚本演示了使用PHP生成Excel的基本流程创建新工作簿、添加工作表、向单元格写入数据、调整简单样式并保存文件整个操作无需依赖Microsoft Excel软件本身配合PHPExcel或PhpSpreadsheet等主流库可快速理解其核心API与写法jpg图还能直观查看示例输出效果。已有154人学习下载对希望掌握PHP导出Excel基础写法的开发者来说这是一份轻量且能直接对照上手的参考代码。1. PHP 生成 Excel 文件为什么说导出一个 xlsx 比想象中更考验工程能力几乎每个用 PHP 做过后台系统的开发者都会遇到同一个需求把数据库里的数据导出成 Excel。表面上看这只是把数组循环写入表格但真正动手后才会发现生成 Excel 文件的门槛不在「生成」本身而在编码、内存、样式兼容和浏览器下载这一整条链路上。我见过某公司的报表系统因为导出一张五万行的表直接把进程内存耗尽也见过导出 CRLF 换行问题导致 Excel 打开时报文件损坏——这些都不是业务逻辑复杂而是对 Excel 底层机制不够了解。这篇文章要讲清楚一件事用 PHP 生成 Excel 文件的完整落地路径包括选型依据、最小实现、大数据量处理、常见坑点和可复用的封装方案。适合有 PHP 基础、正被导出需求卡住的从业者也适合想把导出模块做得更稳的工程师。读完你不仅能用代码跑通一个带样式的文件还能知道在什么数据量下必须换方案。2. 选型对比PHPExcel、PhpSpreadsheet、原生 CSV 各自该在什么场景用2.1 为什么 PHPExcel 已不值得新项目用PhpSpreadsheet 是默认起点PHPExcel 曾经是 PHP 生成 Excel 的标配库但它的维护早已停止官方也直接把新项目指向了 PhpSpreadsheet。PhpSpreadsheet 是它的继承者命名空间、API 风格都做了重构改用 Composer 管理依赖内部对命名空间和异常处理也做得更规范。新项目如果还在网上找 PHPExcel 的旧片段很可能装上后就和 PHP 8 的语法不兼容翻车的概率很高。我一般建议所有新项目直接采用 PhpSpreadsheet哪怕你只需要生成一个最简单的 xlsx。原因有两个一是它支持 xlsx、ods、csv 多种格式后续需求扩展不需要换库二是它的内存缓存机制可以针对大数据量做配置这是旧库给不了的。若维护的是老项目且无法升级那继续用 PHPExcel 是妥协而不是选型。2.2 三选一小数据带样式、海量数据只求快、还是临时报表如果是存量老项目还在用 PHPExcel建议能迁就迁。常见的选型维度主要是三个数据量、样式复杂度、运行环境内存限制。我做选型时一般按下面的边界来分场景推荐方案理由数据量 1 万行以内需要样式、合并单元格、公式、图表PhpSpreadsheet功能完整能直接输出真正的 xlsx数据量 1 万到 10 万行样式要求低只需要可靠数据分批写 CSV 文件内存占用极低生成速度快Excel 可以直接打开超 10 万行且必须 xlsx 格式分批输出多个文件或配合后端任务异步生成单文件过大容易让 Excel 本身卡顿拆分更稳临时调试、快速验证接口直接输出 CSV不用引入库echo 加几个头就完成注意这里的行数只是经验值实际边界取决于服务器内存、PHP memory_limit 和每行数据的字段数。一个字段宽、列数多的表格一万行也可能让内存爆掉。所以选型不能只看行数还要看单元格总数和平均列宽。2.3 用 Composer 引入 PhpSpreadsheet最小依赖安装与目录规划安装 PhpSpreadsheet 本身就是一趟依赖解决的过程。项目里必须先有 Composer然后在项目根目录执行下面的命令composer require phpoffice/phpspreadsheet执行完成后vendor 目录会出现 autoload.php 和 phpoffice 相关包。不要手动在网上下载压缩包解压后 includeComposer 能自动处理 phpoffice/phpspreadsheet 与 symfony/polyfill 等依赖的版本匹配省下后续大量麻烦。我习惯在项目中规划一个 app/Services/Export 目录把导出逻辑单独放一个类里。这样做的好处是以后无论从控制器调用、命令行调用还是队列任务调用入口都是同一个服务对象不会出现同一套导出逻辑在三个地方各写一遍的情况。Composer 安装完成后只需要在入口处引入 Composer 的 autoload 文件即可开始编码通常这是 index.php 或框架的 bootstrap 文件require_once __DIR__ . /vendor/autoload.php;之后所有 PhpSpreadsheet 的类都会按命名空间自动加载不用再手动 include 任何一个具体类文件。3. 用 PhpSpreadsheet 跑通第一张 Excel从创建到下载3.1 创建管理工作簿并写入单元格IOFactory 与 setCellValue 的调用关系先用一个最小示例把流程走通。下面的代码从创建空工作簿开始向当前工作表写入两行数据然后通过 IOFactory 输出 xlsx 文件?php require_once __DIR__ . /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, SO20240001); $sheet-setCellValue(B2, 某公司); $sheet-setCellValue(C2, 1280.50); $writer new Xlsx($spreadsheet); $writer-save(/tmp/order_export.xlsx);代码的逻辑分三段第一段用new Spreadsheet()创建一个空工作簿此时默认存在一个名为Worksheet的工作表getActiveSheet()拿到的是当前可操作的那张表第二段用setCellValue按单元格坐标写入值这里可以是字符串、数字、布尔值或公式字符串第三段用Xlsx写入器把内存中的工作簿对象保存到指定路径。setCellValue的第一个参数是单元格坐标写法类似A1、B2也可以传入一个包含行列索引的数组。需要注意它默认把文本当作文本写入不会帮你判断类型数字想要变成 Excel 里的数字格式建议直接传 int 或 float 类型。如果要覆盖已存在的单元格直接用同样的坐标再次调用即可。3.2 设置列宽、表头样式和冻结窗格让表格第一眼能看一张完全没有样式的表导出来也能用但报表使用者要求通常会更高。至少要把列宽、表头加粗、背景色和冻结首行做上这四项是报表的底线use PhpOffice\PhpSpreadsheet\Style\Fill; $sheet-getColumnDimension(A)-setWidth(18); $sheet-getColumnDimension(B)-setWidth(20); $sheet-getColumnDimension(C)-setWidth(12); $sheet-getStyle(A1:C1)-getFont()-setBold(true); $sheet-getStyle(A1:C1)-getFill() -setFillType(Fill::FILL_SOLID) -getStartColor()-setARGB(FFD9E1F2); $sheet-freezePane(A2);getColumnDimension控制列的显示宽度按字符宽度设置中文列名建议不小于 16。表头样式通过getStyle返回的样式对象链式调用setBold(true)加粗Fill::FILL_SOLID表示纯色填充setARGB传入带FF前缀的十六进制颜色。freezePane(A2)的效果是冻结第一行滚动时表头始终可见。注意这里的A2是希望变成可见区域左上角第一个单元格的坐标所以冻结首行写A2若冻结前两行则写A3别搞反。3.3 中英文文件名与浏览器下载Content-Type 与 URL 编码配合保存到指定路径后如果产品需求是要在浏览器里直接弹出下载就要拼接好响应的 Content-Type、Content-Disposition 和长度信息$fileName 订单导出_ . date(Ymd) . .xlsx; header(Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); header(Content-Disposition: attachment; filename . urlencode($fileName) . ); header(Content-Length: . filesize($tempPath)); readfile($tempPath);这里最大的坑是中文文件名。直接拼在Content-Disposition里现代浏览器一般也能识别但老版本浏览器和部分浏览器插件会乱码。稳妥做法是用urlencode对文件名编码后再放进 header。另外Content-Length最好带上否则部分浏览器下载时不会显示进度失败也无法判断是否传输完整。写完 header 之后执行任何输出前都不能再有 BOM 或空白字符否则文件会损坏。4. 大数据量导出内存是主要敌人逐行释放是正确姿势4.1 合理数据量阈值内存、超时、CPU 三个边界先摸清同样是两万行数据字段十列和三四十列最终内存占用会差出好几倍。PhpSpreadsheet 默认会把所有单元格对象存在内存里一个单元格对象包含样式、坐标、值和各种属性轻则几百字节重则上 KB。按五万行、二十列估算内存冲到一两百 MB 很常见而大多数 PHP-FPM 环境把 memory_limit 压在 128M 以内这就是导出白屏和打不开的直接原因。在动手优化前先确认三件事当前memory_limit多少、单次查询返回的数据量多大、目标文件需要带多少样式。如果单元格数量在十万以内且样式简单升级内存配置或者设置内存缓存模式通常就能解决超过这个量级就应该换分批查询结合逐行减少对象保留的写法而不是继续堆内存。4.2 分批查询与逐行写入的组合写法不要一次性从数据库取全量数据填进数组再循环。正确思路是从数据库按批次读取写入 Excel 一行后立即释放这行的引用让 PHP 的垃圾回收能及时处理掉不再需要的数据。下面的写法是常见做法$sheet $spreadsheet-getActiveSheet(); $row 1; $sheet-fromArray([订单号, 客户名, 金额], null, A . $row); $row; $limit 1000; $lastId 0; while (true) { $stmt $pdo-prepare( SELECT id, order_no, customer_name, amount FROM orders WHERE id :lastId ORDER BY id ASC LIMIT :limit ); $stmt-bindValue(:lastId, $lastId, PDO::PARAM_INT); $stmt-bindValue(:limit, $limit, PDO::PARAM_INT); $stmt-execute(); $rows $stmt-fetchAll(PDO::FETCH_ASSOC); if (count($rows) 0) { break; } foreach ($rows as $order) { $sheet-setCellValueExplicit(A . $row, $order[order_no], \PhpOffice\PhpSpreadsheet\Cell\DataType::TYPE_STRING); $sheet-setCellValue(B . $row, $order[customer_name]); $sheet-setCellValue(C . $row, $order[amount]); $row; } unset($rows, $order); $lastId (int) end($rows); $lastId $order[id] ?? $lastId; } unset($rows);分批键选id而不是LIMIT偏移原因在于深偏移量让数据库扫表成本越来越高。setCellValueExplicit用于强制把订单号按字符串写防止十几位数字被 Excel 转成科学计数法。unset的作用是主动释放批数据引用配合 PHP 的引用计数机制在循环结束后回收内存。注意这里$lastId的更新要取本批最后一条记录的 id而不是end($rows)的索引值。4.3 关闭样式计算和自动筛选两个能明显降内存的开关PhpSpreadsheet 在生成文件时会默认计算工作簿的绝对路径、处理自动筛选引用关系这些逻辑在小文件上无感知在大文件上会带来额外开销。可以用两个开关把它关掉$spreadsheet-setAutoFilter(false); $spreadsheet-getCalculationEngine()-setCalculationMode(\PhpOffice\PhpSpreadsheet\Calculation\Calculation::CALCULATION_MODE_MANUAL);第一个开关关闭自动筛选对象在单元格上的构建逻辑第二个开关让写入单元格时不立即计算公式。若表格中确实有公式列改成手动计算模式后在写入结束时调用一次计算即可。这两个开关对内存的影响在表格超过两万行后能明显感知是简单有效的降本手段。另一个容易被忽略的是设置内存缓存模式。PhpSpreadsheet 默认所有单元格都放内存可以通过Settings::setCache切换到文件缓存或内存数组缓存把单元格对象的序列化数据临时放到磁盘而不是整个对象驻留内存。这个选项适合服务器磁盘性能好的场景如果在容器环境使用网络磁盘反而会因频繁读写拖慢速度需要自己测过再决定。4.4 超出内存时的兜底方案CSV 导出与 Excel 打开兼容性如果数据量真的到了十万行以上我的临时出口永远是 CSV。CSV 本质是纯文本逐行写入基本不消耗内存生成速度也比 xlsx 快一个数量级header(Content-Type: text/csv; charsetUTF-8); header(Content-Disposition: attachment; filenameorders.csv); $fp fopen(php://output, w); fwrite($fp, \xEF\xBB\xBF); $header [订单号, 客户名, 金额]; fputcsv($fp, $header); foreach ($rows as $order) { fputcsv($fp, [$order[order_no], $order[customer_name], $order[amount]]); } fclose($fp);这里第一行写入 BOM\xEF\xBB\xBF是为了让 Excel 直接打开这个 CSV 时能识别 UTF-8 编码否则中文会显示成乱码。php://output流直接向响应缓冲区写内容不落磁盘文件适合临时下载场景。这个方案唯一的代价是样式和公式全部丢失只保留纯数据。如果需要带样式就得走异步任务生成真正的 xlsx 文件再提供下载链接。5. 常见问题与避坑重复下载、乱码、卡死、单元格格式错乱5.1 HTTP 头顺序与输出缓冲文件下载失败多半在这两处现象浏览器里点导出没反应或者下载下来是一个 0 字节的文件甚至页面出现一串乱码。原因排查下来大体两类。一类是 PHP 的header调用之前已经有输出比如框架自动渲染的视图模板在控制器前输出了空格或 BOM这些字符被当作响应体的一部分导致文件流头部损坏。另一类是 PHP 开启了输出缓冲readfile后没有exit框架后续又追加了内容进来。解决方法是把 header 和 readfile 放在一个专门的输出方法里写完文件后马上exit。另外在入口文件检查是否引入了带 BOM 的 PHP 文件这种文件在?php前有一个不可见字符最隐蔽。用编辑器把所有项目文件重新存为无 BOM 的 UTF-8可以根除。5.2 文件名中文乱码与 CSV 用 Excel 打开乱码的两种不同修法有些开发者被中文文件名的问题折磨很久最后发现只在特定浏览器出错。需要区分两种乱码场景它们的修法完全不一样场景现象修法xlsx 文件名为中文时下载乱码下载对话框里文件名变成_或乱码header 里用filename*UTF-8格式或者对文件名做 URL 编码CSV 内容中文乱码用 Excel 打开后中文全部是问号或乱码在 CSV 文件开头写入 UTF-8 BOM 字节下载文件名正常但内容是乱码生成过程输出过非 UTF-8 内容检查数据库连接字符集确保SET NAMES utf8mb4看到表格里第一列变成这类字符基本可以确认是重复写入了 BOM。看到文件名乱码优先检查Content-Disposition头的写法。两个问题不要混在一起排查容易越修越乱。5.3 长数字变科学计数法number format 与强制字符串导出的订单号、身份证号、卡号超过 11 位时Excel 默认会转成科学计数法后面好几位的精确值直接丢失。这个问题的根源在 Excel 的单元格数值格式而不是 PHP 传值错了。解决办法有两种。一是在写入时用setCellValueExplicit配合字符串类型让 Excel 不做数值转换二是给单元格设置文本格式后再写入普通字符串$sheet-getStyle(A2)-getNumberFormat()-setFormatCode(); $sheet-setCellValue(A2, 6222020200012345678);格式代码表示强制文本。也可以对整个字段列提前设置$sheet-getStyle(A2:A1000)-getNumberFormat()-setFormatCode();但需要注意getStyle在遍历大范围时本身也会消耗内存建议只把它应用到确实需要的行范围不要对整列A直接设置以免覆盖已有的样式对象。5.4 导出卡死或白屏关闭 autoFilter 后的性能差异我曾遇到一个报表两千行数据导出时页面直接白屏把 memory_limit 调到 1G 才勉强跑通。最后逐行分析时发现业务代码在导出前构建了一个包含大量合并单元格的复杂表头再加上每个单元格设置了动态样式每个单元格对象占用的内存比数据本身还大。白屏和卡死的常见顺序是先出现内存警告或致命错误但 PHP-FPM 把错误吞了不外抛最终表现为白屏。排查时第一步打开错误显示或者查看日志第二步定位到具体哪一行内存暴涨。最有效的解围动作是检查代码里是否有一整列或整大范围统一设置样式的操作。比如setAutoFilter造成的引用关系构建在小文件上没感觉但两万行、二十列时它会在后台创建大量引用对象。关闭后文件依然能正常生成内存却明显下降。这类问题没有统一解需要按实际业务逐步拆开来压测。6. 进阶技巧把导出封装成可复用服务并验证文件完整性6.1 封装导出服务传入表头、数据、文件名三个参数即可复用当多处业务都需要导 Excel 时最怕的就是每个 Controller 里复制一份代码。我一般把导出逻辑收口到一个服务类对外只暴露一个方法传表头、数据和文件名三个参数内部统一处理样式、文件名编码和错误拦截final class ExcelExportService { public function export(array $headers, array $rows, string $fileName): void { $spreadsheet new Spreadsheet(); $sheet $spreadsheet-getActiveSheet(); $sheet-fromArray($headers, null, A1); $rowIndex 2; foreach ($rows as $row) { $sheet-fromArray($row, null, A . $rowIndex); $rowIndex; } $sheet-getStyle(A1: . $sheet-getHighestColumn() . 1) -getFont()-setBold(true); $tempPath sys_get_temp_dir() . / . uniqid(export_, true) . .xlsx; $writer new Xlsx($spreadsheet); $writer-save($tempPath); header(Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); header(Content-Disposition: attachment; filename . urlencode($fileName) . ); header(Content-Length: . filesize($tempPath)); readfile($tempPath); unlink($tempPath); exit; } }服务类做了三件关键事一是不直接把数据写入数据库生成的文件再让用户下载那份文件而是先落到临时文件名再加 header 流输出下载完成后立刻删除避免磁盘堆垃圾文件二是从表头最后一列开始批量设置样式避免逐单元格调用三是所有输出路径统一到这里后续加权限校验或操作日志都不用改动控制器。注意这个写法默认不处理超大数组若调用方传入十万行数据内存同样会爆需要在前置查询处分批调用多次该服务或改用 CSV 分支。6.2 验证技巧用 unzip 临时目录检查 xlsx 内部 XML 结构xlsx 本质是一个 zip 包里面包含[Content_Types].xml、xl/worksheets/sheet1.xml、xl/styles.xml等文件。当 Excel 打不开文件时把这些文件解压出来看 XML 结构最直接。在 Linux 环境可以快速检查unzip -l generated.xlsx unzip -p generated.xlsx xl/worksheets/sheet1.xml | head -20如果sheet1.xml中的row标签数量不对或者出现未转义的字符说明写入内容里有特殊字符污染了 XML 结构。最常见的来源是内容里的、、没有按 XML 规范转义。PhpSpreadsheet 内部已处理转义但如果你中途用字符串拼接方式拼过单元格内容就会碰到这个问题。遇到文件损坏时先做这个检查再排查业务逻辑能省去很多无头绪的时间。6.3 给导出任务加日志落点记录行数、耗时和内存峰值生产环境最好记录每次导出的关键指标否则出问题时无从判定是数据量变大还是代码回归。我在导出服务里加了一个简单的日志点$startMemory memory_get_usage(true); $startTime microtime(true); // ... 执行导出逻辑 ... $logData [ time date(Y-m-d H:i:s), rows count($rows), duration_ms round((microtime(true) - $startTime) * 1000), memory_peak_mb round(memory_get_peak_usage(true) / 1024 / 1024, 2), ]; error_log(json_encode($logData, JSON_UNESCAPED_UNICODE), 3, /var/log/export.log);通过统计连续几周的日志可以看到导出量的增长曲线以及哪些表结构变化导致了内存的阶梯上升。经验法则是当内存峰值超过memory_limit的七成时就该提前切换成分批方案或换 CSV 兜底不要等到线上用户点了导出才发现失败。这个做法带给我最大的帮助是后来排查每个导出问题都有了数据依据不用靠猜。希望帮到你。本文还有配套的精品资源点击获取