PhpSpreadsheet で .xlsx を出力できるようになると、次に細かく詰まるのが列幅・数式の値・複数ファイルの配布だ。列がはみ出す、数式を入れたのに値が取れない、複数のファイルをまとめて 1 回で渡したい——こうした要件を、素の PHP で解く 3 つの小ネタをまとめる。
1. ゴールと非対象
ゴール
到達する状態:
- 列幅を内容に合わせて自動調整し、崩れやすい列だけ固定できる
- セルの数式について、数式そのもの・計算結果・表示文字列を用途で使い分けられる
- 複数の
.xlsxを 1 つの.zipにまとめてブラウザからダウンロードできる
非対象
扱わない内容:
- テンプレート
.xlsxを読み込んで値を埋める方式(別記事のスコープ) - 明細行が可変で増える帳票の行挿入・スタイル複製
- CSV / PDF など
.xlsx以外の形式 - 大量行を出すときのメモリ最適化
- DB からの実データ取得や認証付きダウンロード
2. デモ環境を作って PhpSpreadsheet を導入する
Windows のターミナル(Windows Terminal や PowerShell)を開き、wsl で WSL(Ubuntu)に入ります。
wsl
プロンプトが Ubuntu のもの(ユーザー名@ホスト名:~$ のような表示)に変われば、WSL の中です。ここから作業ディレクトリを作ります。
mkdir -p ~/projects/php-phpspreadsheet-practical-tips-demo
cd ~/projects/php-phpspreadsheet-practical-tips-demo
mkdir -p docker/php public scripts src out
code .
以降のコマンドは、この WSL 内の ~/projects/php-phpspreadsheet-practical-tips-demo で実行します。
2-1. Docker 環境を用意する
compose.yml を作成します。
services:
app:
build:
context: .
dockerfile: docker/php/Dockerfile
working_dir: /workspace
volumes:
- ./:/workspace
ports:
- "8080:8080"
command: ["sleep", "infinity"]
docker/php/Dockerfile を作成します。PhpSpreadsheet が必要とする拡張と、zip 出力で使う zip 拡張を含めます。
FROM php:8.5-cli
RUN apt-get update && apt-get install -y \
libzip-dev \
libpng-dev \
unzip \
&& docker-php-ext-install zip gd \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
zip 拡張は 5 章の ZipArchive でそのまま使います。app サービスは command: ["sleep", "infinity"] で常駐させ、docker compose exec app ... を主線にします。
composer.json を作成します。
{
"name": "demo/excel-tips",
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"require": {
"php": "^8.1"
}
}
2-2. コンテナを起動して PhpSpreadsheet を導入する
docker compose up -d --build
起動を確認します。
docker compose exec app php -v
docker compose exec app composer --version
docker compose exec app php -m
PhpSpreadsheet を導入します。
docker compose exec app composer require phpoffice/phpspreadsheet:^5
composer.lock と vendor/ が生成されれば準備完了です。
詰まったら
docker compose logs appでコンテナのログを見る- Dockerfile を直したら
docker compose up -d --buildで再ビルドする - 拡張不足で
composer requireが失敗したら、エラーに出た拡張を Dockerfile へ追加して再ビルドする
拡張が足りないときの出力はこうなります(gd を入れずに実行した場合)。
Your requirements could not be resolved to an installable set of packages.
Problem 1
- Root composer.json requires phpoffice/phpspreadsheet ^5 -> satisfiable by phpoffice/phpspreadsheet[5.8.1, 5.9.0].
- phpoffice/phpspreadsheet[5.8.1, ..., 5.9.0] require ext-gd * -> it is missing from your system. Install or enable PHP's gd extension.
To enable extensions, verify that they are enabled in your .ini files:
- /usr/local/etc/php/conf.d/docker-php-ext-sodium.ini
You can also run `php --ini` in a terminal to see which files are used by PHP in CLI mode.
Alternatively, you can run Composer with `--ignore-platform-req=ext-gd` to temporarily ignore these required extensions.
Installation failed, reverting ./composer.json to its original content.
読む順序は次の 3 点です。
require ext-gd * -> it is missing from your systemの行に、足りない拡張名がそのまま出る。複数足りなければ複数行出る- 最終行の
reverting ./composer.json to its original contentは、composer.jsonが書き戻されたという意味。失敗した状態が残るわけではないので、拡張を入れてから同じコマンドを実行し直せばよい - メッセージ中の
--ignore-platform-req=ext-gdはチェックを飛ばすだけで拡張は入らない。ここでは使わず、docker/php/Dockerfileのdocker-php-ext-installに拡張名を足してdocker compose up -d --buildで作り直します
3. 列幅の自動調整(幅が内容に合わないとき)
出力した .xlsx を開くと、内容がセル幅に収まらず切れて見えることがある。列幅を内容に合わせる仕組みが setAutoSize(true) です。
scripts/autosize.php を作成します。
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->fromArray(
[
['商品名', '数量', '単価'],
['A4コピー用紙 5000枚(高白色・両面印刷対応)', 12, 3200],
['再生封筒 長3 1000枚', 5, 1800],
],
null,
'A1',
);
// すべての列を内容に合わせて自動調整する
foreach (['A', 'B', 'C'] as $col) {
$sheet->getColumnDimension($col)->setAutoSize(true);
}
$outDir = __DIR__ . '/../out';
if (!is_dir($outDir)) {
mkdir($outDir, 0755, true);
}
(new Xlsx($spreadsheet))->save($outDir . '/autosize.xlsx');
$spreadsheet->disconnectWorksheets();
// 保存後に読み戻して、確定した幅を確認する
$reloaded = IOFactory::load($outDir . '/autosize.xlsx');
$rs = $reloaded->getActiveSheet();
foreach (['A', 'B', 'C'] as $col) {
$dim = $rs->getColumnDimension($col);
printf("列%s width=%s autoSize=%s\n", $col, var_export($dim->getWidth(), true), var_export($dim->getAutoSize(), true));
}
$reloaded->disconnectWorksheets();
echo "saved: out/autosize.xlsx\n";
実行します。
docker compose exec app php scripts/autosize.php
出力は次のようになります。
列A width=51.845 autoSize=false
列B width=5.856 autoSize=false
列C width=5.856 autoSize=false
out/autosize.xlsx を Excel や LibreOffice Calc で開くと、どの列も内容に合わせて幅が調整され、長い商品名も切れずに収まっています。幅指定なしのときと並べると違いが分かります。
幅指定なし(商品名が隣の列にぶつかって切れる):
全列を autosize(商品名も含めて内容に合わせて広がり、切れない):
読み戻した幅に注目してください。autosize を指定した列は、保存後はどれも autoSize=false で具体的な数値(A なら 51.845、B・C なら 5.856)が入っています。幅は保存時に一度だけ算出され、ファイルには固定値として焼き込まれる。生成後にファイルを読み込み直して再加工するコードでは、autoSize フラグが残っている前提を置かないほうが安全です。
autosize の限界と、幅を固定するときの単位
autosize は「表示される最も広い値」に幅を近似する仕組みで、ぴったり合うとは限りません。公式 FAQ でも、PhpSpreadsheet が指定する幅は Excel の実測より約 0.71 狭くなる(余白込みで測っているため)と説明されています。プロポーショナルフォントや日本語では、Excel の描画結果とずれることもあります。
特定の幅に固定したい列は setWidth() を使いますが、単位に注意が必要です。列幅の単位は既定フォントの半角文字数が基準で、全角の日本語はおよそ 2 文字分を使います。上の商品名は全角換算で 40 文字分を超えるため、setWidth(40) にすると末尾が切れます。固定するなら、autosize が算出した幅(この例では約 51.8)を目安に、余裕を持たせて指定します。
コードのポイント
① 列を内容に合わせて自動調整する
foreach (['A', 'B', 'C'] as $col) {
$sheet->getColumnDimension($col)->setAutoSize(true);
}
getColumnDimension('列')->setAutoSize(true) で内容幅に近似させる。幅は保存時に算出されてファイルへ焼き込まれる。特定の幅に固定したい列だけ setWidth() を使い分ける(単位は半角文字数基準なので、全角は約 2 文字分で数える)。
4. 数式の値の扱い(数式を入れたのに値が取れないとき)
セルに数式を入れたのに「空になる」「=C2*D2 という文字列が返る」という詰まりは、取得メソッドの取り違えで起きることが多い。PhpSpreadsheet はセルの値を 3 通りの見方で返します。
scripts/formula.php を作成します。
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use PhpOffice\PhpSpreadsheet\Calculation\Calculation;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setCellValue('C2', 12); // 数量
$sheet->setCellValue('D2', 3200); // 単価
$sheet->setCellValue('E2', '=C2*D2'); // 小計
$sheet->getStyle('E2')->getNumberFormat()->setFormatCode('#,##0');
$cell = $sheet->getCell('E2');
echo "== 3つの取得メソッド ==\n";
printf("getValue() = %s\n", var_export($cell->getValue(), true));
printf("getCalculatedValue()= %s\n", var_export($cell->getCalculatedValue(), true));
printf("getFormattedValue() = %s\n", var_export($cell->getFormattedValue(), true));
echo "\n== キャッシュの罠 ==\n";
$sheet->setCellValue('C2', 20); // 数量を書き換える
printf("C2を20に変更後 getCalculatedValue()= %s (期待:64000)\n", var_export($sheet->getCell('E2')->getCalculatedValue(), true));
Calculation::getInstance($spreadsheet)->clearCalculationCache();
printf("clearCalculationCache()後 = %s\n", var_export($sheet->getCell('E2')->getCalculatedValue(), true));
echo "\n== =始まりの文字列を数式にしない ==\n";
$sheet->setCellValue('A5', '=SUM(A1:A4)'); // 数式として解釈される
$sheet->setCellValue('A6', '=SUM(A1:A4)');
$sheet->getStyle('A6')->setQuotePrefix(true); // 文字列として扱う
printf("A5 getValue=%s isFormula=%s\n", var_export($sheet->getCell('A5')->getValue(), true), var_export($sheet->getCell('A5')->isFormula(), true));
printf("A6 getValue=%s isFormula=%s\n", var_export($sheet->getCell('A6')->getValue(), true), var_export($sheet->getCell('A6')->isFormula(), true));
$spreadsheet->disconnectWorksheets();
実行します。
docker compose exec app php scripts/formula.php
出力は次のとおりです。
== 3つの取得メソッド ==
getValue() = '=C2*D2'
getCalculatedValue()= 38400
getFormattedValue() = '38,400'
== キャッシュの罠 ==
C2を20に変更後 getCalculatedValue()= 38400 (期待:64000)
clearCalculationCache()後 = 64000
== =始まりの文字列を数式にしない ==
A5 getValue='=SUM(A1:A4)' isFormula=true
A6 getValue='=SUM(A1:A4)' isFormula=false
3 つの取得メソッドは、返すものが違います。
getValue()はセルに入っている生の値。数式セルなら=C2*D2という数式そのものgetCalculatedValue()は数式を評価した計算結果。38400getFormattedValue()は表示上の文字列。表示形式#,##0を適用した38,400
「数式を入れたのに文字列が返る」の多くは、計算結果が欲しい場面で getValue() を呼んでいるだけです。計算結果なら getCalculatedValue()、画面表示に合わせた文字列なら getFormattedValue() を使います。
計算結果はキャッシュされる
出力の 2 つめのブロックが要注意です。C2 を 20 に書き換えたのに、getCalculatedValue() は古い 38400 を返しています。計算エンジンは一度評価した結果をキャッシュするため、元データを書き換えても同じセルの再取得では古い値が返る。Calculation::getInstance($spreadsheet)->clearCalculationCache() でキャッシュを消すと、次の取得で 64000 に更新されます。
値を差し替えながら計算結果を読み直すループでは、この挙動を踏みやすい。書き換えのあとで計算結果を取り直すなら、間でキャッシュを消します。
= で始まる文字列を数式にしない
= で始まる値は数式として解釈されます。品番や式の説明文など、= 始まりの文字列をそのまま入れたいときは、setQuotePrefix(true) を付けると文字列として扱われます。出力の A6 は isFormula=false になっています。
コードのポイント
① 用途で 3 つの取得メソッドを選ぶ
$cell = $sheet->getCell('E2');
printf("getValue() = %s\n", var_export($cell->getValue(), true));
printf("getCalculatedValue()= %s\n", var_export($cell->getCalculatedValue(), true));
printf("getFormattedValue() = %s\n", var_export($cell->getFormattedValue(), true));
getValue() は数式そのもの、getCalculatedValue() は計算結果、getFormattedValue() は表示形式を適用した文字列を返す。DB へ入れる数値なら計算結果、画面やメールに載せる文言なら表示文字列、と欲しいものから逆算して選ぶ。
② 元データを書き換えたらキャッシュを消す
$sheet->setCellValue('C2', 20); // 数量を書き換える
printf("C2を20に変更後 getCalculatedValue()= %s (期待:64000)\n", var_export($sheet->getCell('E2')->getCalculatedValue(), true));
Calculation::getInstance($spreadsheet)->clearCalculationCache();
printf("clearCalculationCache()後 = %s\n", var_export($sheet->getCell('E2')->getCalculatedValue(), true));
計算エンジンはセルごとの評価結果をキャッシュする。C2 を書き換えた直後の再取得は古い値のままで、clearCalculationCache() を挟むと次の取得で再評価される。入力を差し替えながら計算結果を読み直すループで踏みやすい。
5. 複数ファイルの zip 一括ダウンロード
複数の .xlsx を別々に出したいが、ダウンロードは 1 回で済ませたい——この要件は、生成したファイルを 1 つの .zip にまとめると解けます。zip 化には PHP 標準の ZipArchive を使う。2 章の Dockerfile で zip 拡張を入れてあるので追加導入は不要です。
まずレポート 1 件分を表すデータクラス src/ReportData.php を作成します。
<?php
declare(strict_types=1);
namespace App;
final class ReportData
{
/**
* @param list<array{0: string, 1: int, 2: int}> $rows 商品名・数量・単価の行
*/
public function __construct(
public readonly string $title,
public readonly array $rows,
) {}
/** @return list<self> zip にまとめるサンプル */
public static function samples(): array
{
return [
new self('レポートA', [
['A4コピー用紙 5000枚', 12, 3200],
['再生封筒 長3 1000枚', 5, 1800],
]),
new self('レポートB', [
['ボールペン 黒 10本', 40, 480],
['クリアファイル A4', 100, 38],
]),
new self('レポートC', [
['養生テープ 50mm', 24, 260],
]),
];
}
}
次に、ブラウザから zip をダウンロードする public/download-zip.php を作成します。
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use App\ReportData;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
/** レポート1件分の .xlsx を一時ファイルに書き出してパスを返す */
function buildXlsx(ReportData $data): string
{
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setCellValue('A1', $data->title);
$sheet->fromArray([['商品名', '数量', '単価']], null, 'A3');
$row = 4;
foreach ($data->rows as $r) {
$sheet->fromArray([$r], null, "A{$row}");
$row++;
}
$tmp = tempnam(sys_get_temp_dir(), 'xlsx_');
(new Xlsx($spreadsheet))->save($tmp);
$spreadsheet->disconnectWorksheets();
return $tmp;
}
// zip も一時ファイルに作る
$zipPath = tempnam(sys_get_temp_dir(), 'zip_');
$zip = new ZipArchive();
$zip->open($zipPath, ZipArchive::OVERWRITE);
$tempFiles = [];
foreach (ReportData::samples() as $data) {
$tmp = buildXlsx($data);
$tempFiles[] = $tmp;
$zip->addFile($tmp, $data->title . '.xlsx');
}
$zip->close();
// zip を閉じてから .xlsx の一時ファイルを片付ける
foreach ($tempFiles as $tmp) {
unlink($tmp);
}
$filename = 'reports.zip';
header('Content-Type: application/zip');
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Content-Length: ' . filesize($zipPath));
header('Cache-Control: max-age=0');
readfile($zipPath);
unlink($zipPath);
開発サーバーを起動します。
docker compose exec app php -S 0.0.0.0:8080 -t public
ブラウザで http://localhost:8080/download-zip.php を開くと、.zip がダウンロードされます。中身は .xlsx 3 ファイルです。
レスポンスを確認したいときは、別のターミナルから curl を使います。
docker compose exec app curl -sI http://localhost:8080/download-zip.php
確認したいのは次の 2 つのヘッダです。
Content-Type: application/zip
Content-Disposition: attachment; filename="reports.zip"
Content-Type が application/zip で、Content-Disposition: attachment が付いていればダウンロードになります。
.xlsx も .zip も binary です。header() より前に echo や空白・BOM が混ざると壊れるため、<?php の前に空行を入れない、?> の閉じタグは書かない、という基本に注意してください。
一時ファイルは zip を閉じてから消す
ZipArchive::addFile() は、その場でファイルを読み込むのではなく、close() のタイミングでまとめて zip に書き込みます。.xlsx の一時ファイルを消すのは close() のあとです。先に消すと空の zip になります。zip 自体も一時ファイルに作り、readfile() で送ってから削除すると、出力後に消し忘れが残りません。
コードのポイント
① .xlsx を一時ファイルにして ZipArchive へ追加する
$zip = new ZipArchive();
$zip->open($zipPath, ZipArchive::OVERWRITE);
$tempFiles = [];
foreach (ReportData::samples() as $data) {
$tmp = buildXlsx($data);
$tempFiles[] = $tmp;
$zip->addFile($tmp, $data->title . '.xlsx');
}
$zip->close();
foreach ($tempFiles as $tmp) {
unlink($tmp);
}
addFile($tmp, 'エントリ名') の第 2 引数が zip 内のファイル名になる。日本語名もそのまま渡せる。addFile() は close() 時に読み込むため、一時ファイルの削除は close() の後に回す。
6. まとめ
3 つの小ネタを素の PHP で確認しました。
- 列幅 — 内容が安定した列は
setAutoSize(true)、切れると困る列はsetWidth()で固定。autosize は近似なので崩れると困る列は固定側に寄せる - 数式 —
getValue()/getCalculatedValue()/getFormattedValue()を用途で選ぶ。入力を書き換えたらclearCalculationCache()で再評価させる - zip —
.xlsxを一時ファイルにしてZipArchiveでまとめ、close()のあとに一時ファイルを消す
見た目を固定したい帳票なら、スタイルや罫線をあらかじめ持たせた .xlsx テンプレートを読み込んで値だけ差し込む方式が向きます。テンプレート方式の組み立ては PHP + PhpSpreadsheet でExcel帳票を出力する(テンプレート方式) で扱っています。明細行が可変で増える帳票なら、行を動的に挿入してスタイルを複製する方式が次の課題になります。