公開日 2026-08-28

PHP + PhpSpreadsheet で可変明細の帳票を出力する(行挿入とスタイル複製)

テンプレートの固定行数を超える可変明細を、PhpSpreadsheet の insertNewRowBefore で行挿入し、duplicateStyle で罫線・数値書式を複製して、合計まで正しく計算される .xlsx 帳票を素の PHP で出力する。

目次

  1. 1. ゴールと非対象
  2. ゴール
  3. 非対象
  4. 2. デモ環境を作って PhpSpreadsheet を導入する
  5. 2-1. Docker 環境を用意する
  6. 2-2. コンテナを起動して PhpSpreadsheet を導入する
  7. 詰まったら
  8. 3. 再現用のテンプレートを生成する
  9. 4. 明細が固定行を超えるとどうなるか
  10. 5. insertNewRowBefore で行を挿入し、合計の SUM を自動調整させる
  11. コードのポイント
  12. 6. 挿入した行にスタイルと数式を複製する
  13. 詰まったら
  14. コードのポイント
  15. 7. ブラウザから .xlsx をダウンロードする
  16. 8. 出力を確認し、詰まりどころを整理する
  17. 詰まりどころ一覧
  18. 9. まとめと次の一歩

テンプレート .xlsx に用意した明細行数を超えるデータが来ると、固定の枠だけでは足りません。PhpSpreadsheetinsertNewRowBefore() で明細行を必要数だけ挿入し、duplicateStyle() で罫線や数値書式を新しい行へ複製すれば、合計まで正しく計算される帳票を素の PHP で作れます。この記事で組むのはその一連の流れです。

1. ゴールと非対象

ゴール

到達する状態:

  • 明細 5 行分のテンプレートに、5 件を超えるデータを行挿入で流し込める
  • 挿入した行にも罫線・数値書式・=数量*単価 の数式が入る
  • 合計セルの =SUM(...) が挿入に追従し、増えた行まで合算される
  • ブラウザから完成した .xlsx をダウンロードできる

明細の件数が実行時まで決まらない帳票では、テンプレート側の固定行だけでは足りない。行を動的に足し、テンプレートが持っている見た目を新しい行へ引き継ぐのがこの記事の主題になる。

非対象

扱わない内容:

  • テンプレート方式そのものの詳しい解説(再現用テンプレートは 3 章で 1 回だけ作ります)
  • 画像・チャート・マクロ・パスワード保護付きブック
  • PDF / CSV など .xlsx 以外の出力形式
  • 大量行を出すときのメモリ最適化
  • DB からの実データ取得や認証付きダウンロード
  • 列の挿入・削除、シートを跨いだ行コピー

2. デモ環境を作って PhpSpreadsheet を導入する

Windows のターミナル(Windows Terminal や PowerShell)を開き、wsl で WSL(Ubuntu)に入ります。

wsl

プロンプトが Ubuntu のもの(ユーザー名@ホスト名:~$ のような表示)に変われば、WSL の中です。ここから作業ディレクトリを作ります。

mkdir -p ~/projects/php-phpspreadsheet-row-insertion-demo
cd ~/projects/php-phpspreadsheet-row-insertion-demo
mkdir -p docker/php public scripts src templates
code .

以降のコマンドは、この WSL 内の ~/projects/php-phpspreadsheet-row-insertion-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 が必要とする拡張を含めます。

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

app サービスは command: ["sleep", "infinity"] で常駐させ、docker compose exec app ... を主線にします。先にコンテナを起動したままにしておかないと service "app" is not running になります。

composer.json を作成します。

{
    "name": "demo/variable-report",
    "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.lockvendor/ が生成されれば準備完了です。

詰まったら

  • 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.

require ext-gd * -> it is missing from your system の行に、足りない拡張名がそのまま出ます。 複数足りなければ複数行出ます。メッセージ中の --ignore-platform-req=ext-gdチェックを飛ばすだけで拡張は入らないので、ここでは使わず docker/php/Dockerfiledocker-php-ext-install に足して作り直してください。

3. 再現用のテンプレートを生成する

実務では既存のテンプレートをそのまま使いますが、記事ではバイナリ配布に依存しないよう、スクリプトで 1 回だけ生成します。これは本題の帳票出力コードではなく、再現用の下ごしらえです。

作るテンプレートは、明細を 5 行分(7〜11 行目)用意し、12 行目の合計セルに =SUM(E7:E11)、各明細行の小計に =C*D を仕込んだもの。罫線と数値書式もこの段階で入れておく。

scripts/create-template.php を作成します。

<?php

declare(strict_types=1);

require __DIR__ . '/../vendor/autoload.php';

use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;

$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('明細');

// --- 列幅 ---
$sheet->getColumnDimension('A')->setWidth(6);   // No.
$sheet->getColumnDimension('B')->setWidth(28);  // 品目
$sheet->getColumnDimension('C')->setWidth(10);  // 数量
$sheet->getColumnDimension('D')->setWidth(14);  // 単価
$sheet->getColumnDimension('E')->setWidth(16);  // 小計

// --- 帳票タイトル ---
$sheet->setCellValue('A1', '明細レポート');
$sheet->mergeCells('A1:E1');
$sheet->getStyle('A1')->getFont()->setSize(16)->setBold(true);
$sheet->getStyle('A1')->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);

// --- ヘッダ情報エリア ---
$sheet->setCellValue('A3', '発行日:');
$sheet->setCellValue('B3', '');  // コードで埋める

// --- 見出し行 ---
$headerRow = 6;
$headers = ['No.', '品目', '数量', '単価', '小計'];
foreach ($headers as $col => $header) {
    $cell = chr(ord('A') + $col) . $headerRow;
    $sheet->setCellValue($cell, $header);
}

$headerRange = "A{$headerRow}:E{$headerRow}";
$sheet->getStyle($headerRange)->getFont()->setBold(true);
$sheet->getStyle($headerRange)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
$sheet->getStyle($headerRange)->getBorders()->getAllBorders()
    ->setBorderStyle(Border::BORDER_THIN);

// --- 明細行(5行分) ---
$detailStartRow = 7;
$detailEndRow = 11;

for ($row = $detailStartRow; $row <= $detailEndRow; $row++) {
    $range = "A{$row}:E{$row}";
    $sheet->getStyle($range)->getBorders()->getAllBorders()
        ->setBorderStyle(Border::BORDER_THIN);

    // 小計列に数式を入れておく(数量 × 単価)
    $sheet->setCellValue("E{$row}", "=C{$row}*D{$row}");
}

// --- 合計行 ---
$totalRow = 12;
$sheet->setCellValue("D{$totalRow}", '合計');
$sheet->getStyle("D{$totalRow}")->getFont()->setBold(true);
$sheet->setCellValue("E{$totalRow}", "=SUM(E{$detailStartRow}:E{$detailEndRow})");
$sheet->getStyle("E{$totalRow}")->getFont()->setBold(true);
$sheet->getStyle("D{$totalRow}:E{$totalRow}")->getBorders()->getAllBorders()
    ->setBorderStyle(Border::BORDER_THIN);

// --- 数値書式 ---
$sheet->getStyle("C{$detailStartRow}:C{$detailEndRow}")
    ->getNumberFormat()->setFormatCode('#,##0');
$sheet->getStyle("D{$detailStartRow}:E{$totalRow}")
    ->getNumberFormat()->setFormatCode('#,##0');

// --- 保存 ---
$outputDir = __DIR__ . '/../templates';
if (!is_dir($outputDir)) {
    mkdir($outputDir, 0755, true);
}

$writer = new Xlsx($spreadsheet);
$writer->save($outputDir . '/report-template.xlsx');

$spreadsheet->disconnectWorksheets();

echo "テンプレートを生成しました: templates/report-template.xlsx\n";

テンプレートを生成します。

docker compose exec app php scripts/create-template.php

生成を確認します。

docker compose exec app ls templates/

report-template.xlsx が表示されれば成功です。表計算ソフトで開くと、5 行分の明細枠と 12 行目の合計、罫線が入っています。

生成したテンプレート(5行の明細枠と合計行)

4. 明細が固定行を超えるとどうなるか

テンプレートが用意しているのは 5 行分だ。ここに 8 件を素朴に書き込むと何が起きるか、先に実測しておく。テンプレート方式では「準備した行数を超えたら例外で止める」という選択肢があった。この記事は、止める代わりに行を増やす方向へ進む。増やさないまま書くとどう壊れるかを先に見ておくと、行挿入が必要な理由がはっきりする。

scripts/without-insert.php を作成します。

<?php

declare(strict_types=1);

require __DIR__ . '/../vendor/autoload.php';

use PhpOffice\PhpSpreadsheet\IOFactory;

$items = [
    ['ノート A5', 100, 150], ['ボールペン 黒', 200, 80], ['クリアファイル', 50, 120],
    ['付箋 75x75', 30, 210], ['マーカー 4色', 24, 360], ['封筒 長3', 500, 18],
    ['ガムテープ', 12, 240], ['ホチキス針', 40, 95],
];

$spreadsheet = IOFactory::load(__DIR__ . '/../templates/report-template.xlsx');
$sheet = $spreadsheet->getActiveSheet();

// テンプレートは 5 行分(7〜11 行目)しか用意していないのに 8 件を素朴に書く
foreach ($items as $index => $item) {
    $row = 7 + $index;
    $sheet->setCellValue("A{$row}", $index + 1);
    $sheet->setCellValue("B{$row}", $item[0]);
    $sheet->setCellValue("C{$row}", $item[1]);
    $sheet->setCellValue("D{$row}", $item[2]);
    $sheet->setCellValue("E{$row}", "=C{$row}*D{$row}");
}

// 合計行(もとは 12 行目)がどうなったかを確認する
$totalRow = null;
foreach (range(7, 20) as $row) {
    if ($sheet->getCell("D{$row}")->getValue() === '合計') {
        $totalRow = $row;
    }
}
echo '合計行: ' . ($totalRow === null ? '見つからない(上書きされた)' : "{$totalRow} 行目") . "\n";
echo '12 行目 D 列: ' . var_export($sheet->getCell('D12')->getValue(), true) . "\n";
echo '12 行目 E 列: ' . var_export($sheet->getCell('E12')->getValue(), true) . "\n";

$spreadsheet->disconnectWorksheets();

実行します。

docker compose exec app php scripts/without-insert.php

出力は次のようになります。

合計行: 見つからない(上書きされた)
12 行目 D 列: 18
12 行目 E 列: '=C12*D12'

6 件目のデータが 12 行目に書き込まれ、もとの 合計 ラベルと =SUM(E7:E11) を上書きしています。7・8 件目はさらに下の 13・14 行目へ流れ込み、合計行そのものが帳票から消えました。件数がテンプレートの枠を超えたら、まず行を挿入して枠を広げる必要があります。

5. insertNewRowBefore で行を挿入し、合計の SUM を自動調整させる

行を増やすメソッドが insertNewRowBefore($row, $count) だ。$row の手前に $count 行の空行を挿入し、それより下の既存行を押し下げる。押し下げにあわせて、数式内のセル参照も調整される。

ここで注意したいのが、挿入する位置によって合計の SUM 範囲が広がるかどうかが変わる点だ。テンプレートの合計式は =SUM(E7:E11) で、明細 7〜11 行目を合算している。3 行足すとき、合計行(12 行目)の手前に入れるか、最終明細行(11 行目)の手前に入れるかで結果が分かれる。挙動を実測で確かめる。

scripts/check-sum-range.php を作成します。

<?php

declare(strict_types=1);

require __DIR__ . '/../vendor/autoload.php';

use PhpOffice\PhpSpreadsheet\IOFactory;

/** 挿入後の合計行の SUM 式を返す */
function sumFormulaAfterInsert(int $insertBeforeRow, int $count): string
{
    $spreadsheet = IOFactory::load(__DIR__ . '/../templates/report-template.xlsx');
    $sheet = $spreadsheet->getActiveSheet();
    $sheet->insertNewRowBefore($insertBeforeRow, $count);

    $formula = '';
    foreach (range(7, 30) as $row) {
        if ($sheet->getCell("D{$row}")->getValue() === '合計') {
            $formula = (string) $sheet->getCell("E{$row}")->getValue();
        }
    }
    $spreadsheet->disconnectWorksheets();

    return $formula;
}

// テンプレートの明細は 7〜11 行目、合計は 12 行目、式は =SUM(E7:E11)
echo '合計行の直前(12 行目の手前)に 3 行挿入: ' . sumFormulaAfterInsert(12, 3) . "\n";
echo '最終明細行(11 行目)の手前に 3 行挿入:   ' . sumFormulaAfterInsert(11, 3) . "\n";

実行します。

docker compose exec app php scripts/check-sum-range.php

出力は次のとおりです。

合計行の直前(12 行目の手前)に 3 行挿入: =SUM(E7:E11)
最終明細行(11 行目)の手前に 3 行挿入:   =SUM(E7:E14)

差は挿入位置だけだ。合計行の手前(12 行目の手前)は SUM(E7:E11) の範囲の外側にあたり、式は広がらない。新しい行はどこにも合算されず、金額が過少になる。一方、最終明細行(11 行目)の手前は SUM(E7:E11) の範囲の内側なので、式が =SUM(E7:E14) へ自動で広がる。増えた 3 行も合算対象に入る。

方針が決まった。不足分の行は、最終明細行の手前(合計範囲の内側) に挿入します。

コードのポイント

① 挿入位置で SUM の広がりが変わる

    $spreadsheet = IOFactory::load(__DIR__ . '/../templates/report-template.xlsx');
    $sheet = $spreadsheet->getActiveSheet();
    $sheet->insertNewRowBefore($insertBeforeRow, $count);

insertNewRowBefore() は挿入位置より下を押し下げ、数式参照も調整する。ただし調整されるのは「範囲の内側に行が入った」ときだけで、範囲のすぐ外(合計行の手前)に入れた分は SUM に取り込まれない。合計範囲の内側にあたる最終明細行の手前へ挿入するのが、式を手当てなしで広げるコツになる。

6. 挿入した行にスタイルと数式を複製する

挿入した行は空だ。実測すると、罫線や数値書式は挿入位置の直上行から引き継がれる。ただしこれは挿入位置に依存する不安定な挙動だ。最終明細行の手前に挿入すれば直上は明細行なので都合よく罫線が入るが、先頭明細行の手前に挿入した場合は、直上が見出し行なので太字・中央寄せまで引き継いでしまう。引き継ぎ任せにせず、基準となる明細行のスタイルを duplicateStyle() で明示的に複製すると、挿入位置に関係なく見た目がそろう。

もう 1 つ、挿入した行に数式は入らない。スタイルは直上から来ても、小計セルの =C*D は空のまま。数式は行ごとに書き込む。

まずサンプルデータ src/ReportData.php を作成します。テンプレートの 5 行を超える 8 件を返します。

<?php

declare(strict_types=1);

namespace App;

final class ReportData
{
    /**
     * @param string $issueDate 発行日
     * @param list<array{name: string, quantity: int, unitPrice: int}> $items 明細
     */
    public function __construct(
        public readonly string $issueDate,
        public readonly array $items,
    ) {}

    /** デモ用のサンプルデータ(テンプレートの 5 行を超える 8 件) */
    public static function sample(): self
    {
        return new self(
            issueDate: '2026-04-01',
            items: [
                ['name' => 'ノート A5',      'quantity' => 100, 'unitPrice' => 150],
                ['name' => 'ボールペン 黒',   'quantity' => 200, 'unitPrice' => 80],
                ['name' => 'クリアファイル',   'quantity' => 50,  'unitPrice' => 120],
                ['name' => '付箋 75x75',      'quantity' => 30,  'unitPrice' => 210],
                ['name' => 'マーカー 4色',    'quantity' => 24,  'unitPrice' => 360],
                ['name' => '封筒 長3',        'quantity' => 500, 'unitPrice' => 18],
                ['name' => 'ガムテープ',      'quantity' => 12,  'unitPrice' => 240],
                ['name' => 'ホチキス針',      'quantity' => 40,  'unitPrice' => 95],
            ],
        );
    }
}

次に本体の src/VariableReportBuilder.php を作成します。テンプレートを読み込み、不足分の行を挿入し、各明細行へスタイルを複製してから値と数式を書き込みます。

<?php

declare(strict_types=1);

namespace App;

use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;

final class VariableReportBuilder
{
    private const TEMPLATE_PATH = __DIR__ . '/../templates/report-template.xlsx';
    private const DETAIL_START_ROW = 7;   // 明細の先頭行
    private const PREPARED_ROWS = 5;      // テンプレートが用意している明細行数(7〜11 行目)

    public function build(ReportData $data): Spreadsheet
    {
        $spreadsheet = IOFactory::load(self::TEMPLATE_PATH);
        $sheet = $spreadsheet->getActiveSheet();

        // ヘッダセル
        $sheet->setCellValue('B3', $data->issueDate);

        // 足りない行数だけ、明細帯の内側へ挿入する
        $extra = max(0, count($data->items) - self::PREPARED_ROWS);
        if ($extra > 0) {
            // 最終明細行の手前に入れると SUM(E7:E11) の範囲内になり、合計式が自動で広がる
            $lastPreparedRow = self::DETAIL_START_ROW + self::PREPARED_ROWS - 1; // 11
            $sheet->insertNewRowBefore($lastPreparedRow, $extra);
        }

        // 明細を書き込む。挿入した行にも先頭行のスタイルを複製してから値を入れる
        foreach ($data->items as $index => $item) {
            $row = self::DETAIL_START_ROW + $index;
            $this->copyDetailStyle($sheet, $row);
            $sheet->setCellValue("A{$row}", $index + 1);
            $sheet->setCellValue("B{$row}", $item['name']);
            $sheet->setCellValue("C{$row}", $item['quantity']);
            $sheet->setCellValue("D{$row}", $item['unitPrice']);
            $sheet->setCellValue("E{$row}", "=C{$row}*D{$row}");
        }

        return $spreadsheet;
    }

    /** 先頭明細行のスタイルを対象行へ複製し、罫線と数値書式を行き渡らせる */
    private function copyDetailStyle(Worksheet $sheet, int $destRow): void
    {
        if ($destRow === self::DETAIL_START_ROW) {
            return;
        }
        foreach (['A', 'B', 'C', 'D', 'E'] as $col) {
            $sheet->duplicateStyle(
                $sheet->getStyle($col . self::DETAIL_START_ROW),
                "{$col}{$destRow}",
            );
        }
    }
}

詰まったら

  • Could not open "..." for reading が出たら、3 章のテンプレート生成を実行済みか確認する
  • クラスが見つからない場合は docker compose exec app composer dump-autoload を実行する

コードのポイント

① 不足分を合計範囲の内側へ挿入する

        $extra = max(0, count($data->items) - self::PREPARED_ROWS);
        if ($extra > 0) {
            $lastPreparedRow = self::DETAIL_START_ROW + self::PREPARED_ROWS - 1; // 11
            $sheet->insertNewRowBefore($lastPreparedRow, $extra);
        }

用意済みの 5 行を超えた分だけを挿入する。挿入位置は最終明細行(11 行目)の手前で、これは =SUM(E7:E11) の範囲の内側にあたる。5 章で実測したとおり、この位置なら合計式が =SUM(E7:E14) へ自動で広がる。件数が 5 以下なら $extra は 0 になり、挿入は走らない。

② 基準行のスタイルを列ごとに複製する

    private function copyDetailStyle(Worksheet $sheet, int $destRow): void
    {
        if ($destRow === self::DETAIL_START_ROW) {
            return;
        }
        foreach (['A', 'B', 'C', 'D', 'E'] as $col) {
            $sheet->duplicateStyle(
                $sheet->getStyle($col . self::DETAIL_START_ROW),
                "{$col}{$destRow}",
            );
        }
    }

duplicateStyle($sourceStyle, $range) は罫線・数値書式などのスタイルだけを複製する。基準を先頭明細行(7 行目)に固定することで、挿入した行が直上から何を引き継いだかに関わらず、全明細行のスタイルがそろう。列ごとに複製するのは、C〜E 列の #,##0 と A・B 列の書式が違うため。

③ 数式はスタイル複製では入らないので行ごとに書く

        foreach ($data->items as $index => $item) {
            $row = self::DETAIL_START_ROW + $index;
            $this->copyDetailStyle($sheet, $row);
            $sheet->setCellValue("A{$row}", $index + 1);
            $sheet->setCellValue("E{$row}", "=C{$row}*D{$row}");
        }

duplicateStyle() が複製するのはスタイルであって値ではない。挿入した行の小計セルは空のままなので、=C{$row}*D{$row} を行ごとに設定する。行番号は DETAIL_START_ROW + $index で計算し、スタイルを複製してから A〜D に値、E に数式を入れる。

7. ブラウザから .xlsx をダウンロードする

完成した帳票を HTTP レスポンスとして返します。

public/index.php を作成します。

<?php

declare(strict_types=1);

?>
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>可変明細帳票デモ</title>
</head>
<body>
    <h1>可変明細帳票デモ</h1>
    <p><a href="/download.php">明細レポートをダウンロード</a></p>
</body>
</html>

public/download.php を作成します。

<?php

declare(strict_types=1);

require __DIR__ . '/../vendor/autoload.php';

use App\ReportData;
use App\VariableReportBuilder;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;

$data = ReportData::sample();
$builder = new VariableReportBuilder();
$spreadsheet = $builder->build($data);

$filename = 'variable-report.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');

$spreadsheet->disconnectWorksheets();

.xlsx は ZIP ベースのバイナリだ。header() の前に echo や空白・BOM が混ざるとファイルが壊れる。<?php の前に空行を入れない、?> の閉じタグは省略する、という基本に注意する。処理後の disconnectWorksheets() は、Spreadsheet オブジェクトの循環参照を解消してメモリを解放する。

開発サーバーを起動します。

docker compose exec app php -S 0.0.0.0:8080 -t public

ブラウザで http://localhost:8080 にアクセスし、リンクをクリックすると .xlsx がダウンロードされます。

ダウンロードリンクを表示したトップページ

8. 出力を確認し、詰まりどころを整理する

ダウンロードした .xlsx を Excel または LibreOffice Calc で開き、次を確認します。

  • 明細が 8 行(元の 5 行+挿入した 3 行)並んでいる
  • どの明細行にも罫線が入り、数量・単価・小計の桁区切りがそろっている
  • 合計セルが増えた行まで合算している(8 件のサンプルなら 67,620)
  • 合計セルの数式が =SUM(E7:E14) に広がっている
ダウンロードした完成帳票(8行の明細と広がった合計)

レスポンスヘッダだけを確認したい場合は、別のターミナルから curl を使います。

docker compose exec app curl -sI http://localhost:8080/download.php

Content-Type.xlsx の MIME になっていれば、ダウンロードとして扱われます。

Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
Content-Disposition: attachment; filename="variable-report.xlsx"

詰まりどころ一覧

症状原因対処
合計が増えた行を合算しない合計行の手前(SUM 範囲の外側)に挿入している最終明細行の手前(範囲の内側)へ挿入位置を変える
挿入した行に罫線や書式がない直上行からの引き継ぎ任せで、基準行のスタイルを複製していないduplicateStyle() で先頭明細行のスタイルを対象行へ複製する
挿入した行の小計が空数式はスタイル複製では入らない=C{row}*D{row} を行ごとに書き込む
合計行が消える・上書きされる行を挿入せず固定枠を超えて書いている不足分を insertNewRowBefore() で挿入してから書く
ダウンロードしたファイルが壊れて開けないheader() の前に出力が混入している<?php の前の空行と ?> の閉じタグを見直す

9. まとめと次の一歩

行挿入方式で、テンプレートの固定行を超える明細帳票を出力しました。

  1. 不足分を合計範囲の内側へ挿入insertNewRowBefore() を最終明細行の手前で呼び、=SUM を自動で広げた
  2. スタイルは基準行から複製duplicateStyle() で先頭明細行の罫線・数値書式を全行へそろえた
  3. 数式は行ごとに設定 — スタイル複製では入らない =C*D を明細行に書き込んだ

テンプレート方式は、明細行数が決まっている帳票なら固定枠に値を埋めるだけで済みます。件数が実行時まで決まらない帳票では、行挿入とスタイル複製でこの記事の構成に寄せると、合計まで崩さずに出力できます。

明細が数千行を超えると、PhpSpreadsheet がブック全体をメモリに展開する方式では消費が問題になります。行数が大きい帳票を軽く出す方向は、ストリーム出力に特化したライブラリ(OpenSpout など)が候補になります。

シリーズ 3/3

このシリーズ

PhpSpreadsheetで作るExcel帳票

  1. 1. PHP + PhpSpreadsheet でExcel帳票を出力する(テンプレート方式)
  2. 2. PHP + PhpSpreadsheet の実務小ネタ(列幅の自動調整・数式・zip一括出力)
  3. 3. PHP + PhpSpreadsheet で可変明細の帳票を出力する(行挿入とスタイル複製) 現在の記事