公開日 2026-04-30

PHP + PhpSpreadsheet でExcel帳票を出力する(テンプレート方式)

既存のExcelテンプレートを読み込み、固定セルと準備済み明細行へ値を埋めて `.xlsx` をダウンロードする最小構成を理解できる。

目次

  1. 1. ゴールと非対象
  2. ゴール
  3. 非対象
  4. テンプレートについて
  5. 2. デモ環境を作って PhpSpreadsheet を導入する
  6. 2-1. プロジェクトを作成する
  7. 2-2. Docker 環境を用意する
  8. 2-3. Composer を初期化する
  9. 2-4. コンテナを起動して PhpSpreadsheet を導入する
  10. 詰まったら
  11. 3. 再現用の Excel テンプレートを一度だけ生成する
  12. 詰まったら
  13. 4. テンプレートを読み込み、ヘッダセルへ値を入れる
  14. 4-1. サンプルデータを用意する
  15. 4-2. テンプレート読み込みとヘッダ設定
  16. 詰まったら
  17. コードのポイント
  18. 5. 明細行を書き込み、行数超過は例外で止める
  19. 詰まったら
  20. コードのポイント
  21. 6. ブラウザから .xlsx をダウンロードできるようにする
  22. 6-1. トップページを作る
  23. 6-2. ダウンロード処理を作る
  24. 6-3. 開発サーバーを起動する
  25. コードのポイント
  26. 7. 出力結果を確認し、詰まりどころを整理する
  27. 7-1. ダウンロード確認
  28. 7-2. 詰まりどころ一覧
  29. 8. まとめと次の一歩

既存の .xlsx テンプレートを PhpSpreadsheet で読み込み、固定セルと準備済み明細行へ値を埋めてブラウザからダウンロードするまでの最小構成を作ります。スタイルや罫線は PHP 側で一から組まず、テンプレート側に任せる方式です。

1. ゴールと非対象

ゴール

到達する状態:

  • 再現用の Excel テンプレート(.xlsx)をスクリプトで生成できる
  • IOFactory::load() でテンプレートを読み込み、ヘッダセルと明細行へ値を埋められる
  • ブラウザから .xlsx ファイルをダウンロードし、Excel や LibreOffice Calc で開ける

テンプレート方式では、見た目の調整をテンプレート側に寄せることで、PHP 側の責務を「どのセルへ何を入れるか」に絞れます。

非対象

扱わない内容:

  • 可変行の行挿入とスタイル複製(明細が 5 行を超える帳票)
  • 画像・チャート・マクロ・パスワード保護付きブック
  • PDF / CSV など .xlsx 以外の出力形式
  • Laravel Excel などフレームワーク固有のラッパー
  • DB からの実データ取得や認証付きダウンロード
  • 帳票ファイルの長期保存・監査ログ・アクセス権設計

Slim 4 などのフレームワークに組み込む手順は別記事のスコープとします。

テンプレートについて

実務では既にデザイナーや業務担当者が作ったテンプレートがある前提が多いですが、記事の再現性のために、テンプレート生成スクリプトを 1 回だけ使います。このスクリプトは「本題の帳票出力コード」ではなく、再現用テンプレートをそろえる補助です。

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

2-1. プロジェクトを作成する

WSL(Ubuntu)で実行します。

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

以降のコマンドは、~/projects/php-phpspreadsheet-excel-template-demo で実行します。

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

今回の検証で使った php:8.5-cli イメージには domfileinfombstringSimpleXMLxmlxmlreaderxmlwriter が既に入っていたため、Dockerfile では追加で zipgd を有効化しています。以後の composer require で不足があれば、Composer が拡張名を含めて教えてくれます。

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

2-3. Composer を初期化する

composer.json を作成します。

{
    "name": "demo/excel-report",
    "autoload": {
        "psr-4": {
            "App\\": "src/"
        }
    },
    "require": {
        "php": "^8.1"
    }
}

2-4. コンテナを起動して 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 に追加して再ビルドする

3. 再現用の Excel テンプレートを一度だけ生成する

実務では既存のテンプレートをそのまま使いますが、記事ではバイナリファイル配布に依存しないために、スクリプトで 1 回だけ生成します。

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', '');  // コードで埋める
$sheet->setCellValue('A4', '宛先:');
$sheet->setCellValue('B4', '');  // コードで埋める

// --- 見出し行 ---
$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 . '/order-report-template.xlsx');

$spreadsheet->disconnectWorksheets();

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

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

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

生成を確認します。

docker compose exec app ls templates/

order-report-template.xlsx が表示されれば成功です。Excel や LibreOffice Calc で開いて、見出し・罫線・数式が入っていることを確認しても構いません。

生成したテンプレートの確認画面

テンプレート側に見出し・罫線・数式を置いておくことで、PHP の帳票出力コードはセル値の設定だけに集中できます。

詰まったら

  • templates/ ディレクトリが存在しない場合は、スクリプトが自動で作成します。手動で作る必要はありません
  • 生成後に Excel / LibreOffice で開いて見た目を確認しても OK です

4. テンプレートを読み込み、ヘッダセルへ値を入れる

ここからが本題です。IOFactory::load() で既存の .xlsx を読み込み、固定セルへ値を入れる流れを作ります。

4-1. サンプルデータを用意する

src/OrderReportData.php を作成します。

<?php

declare(strict_types=1);

namespace App;

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

    /** デモ用のサンプルデータを返す */
    public static function sample(): self
    {
        return new self(
            issueDate: '2026-03-08',
            customerName: '株式会社サンプル',
            items: [
                ['name' => 'ノート A5',      'quantity' => 100, 'unitPrice' => 150],
                ['name' => 'ボールペン 黒',   'quantity' => 200, 'unitPrice' => 80],
                ['name' => 'クリアファイル',   'quantity' => 50,  'unitPrice' => 120],
            ],
        );
    }
}

4-2. テンプレート読み込みとヘッダ設定

src/ExcelReportBuilder.php を作成します。ヘッダセルだけを埋める版です。

<?php

declare(strict_types=1);

namespace App;

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

final class ExcelReportBuilder
{
    private const TEMPLATE_PATH = __DIR__ . '/../templates/order-report-template.xlsx';
    private const DETAIL_START_ROW = 7;
    private const MAX_DETAIL_ROWS = 5;

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

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

        return $spreadsheet;
    }
}

テンプレート方式では「どのセルをコードで触るか」を最小限に固定するのが読みやすくなります。テンプレートパスは __DIR__ ベースで組みます。相対パスに依存すると、実行ディレクトリが変わったときに File not found になりやすいためです。

日付や文字列を書き込むだけで、テンプレート側のスタイルはそのまま維持されます。

詰まったら

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

コードのポイント

IOFactory::load() で既存テンプレートを読み込む

        $spreadsheet = IOFactory::load(self::TEMPLATE_PATH);
        $sheet = $spreadsheet->getActiveSheet();

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

IOFactory::load() が既存の .xlsx をメモリへ展開する。セルの値・数式・罫線・フォントをすべて引き継いだ状態になるため、PHP 側は「どのセルに何を入れるか」だけを指定すればよい。

5. 明細行を書き込み、行数超過は例外で止める

テンプレートで準備した 5 行に明細を書き込みます。準備行数を超えたら例外で止める方針にします。

src/ExcelReportBuilder.php を次の最終版に置き換えます。

<?php

declare(strict_types=1);

namespace App;

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

final class ExcelReportBuilder
{
    private const TEMPLATE_PATH = __DIR__ . '/../templates/order-report-template.xlsx';
    private const DETAIL_START_ROW = 7;
    private const MAX_DETAIL_ROWS = 5;

    public function build(OrderReportData $data): Spreadsheet
    {
        $this->guardRowCount($data);

        $spreadsheet = IOFactory::load(self::TEMPLATE_PATH);
        $sheet = $spreadsheet->getActiveSheet();

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

        // 明細行
        foreach ($data->items as $index => $item) {
            $row = self::DETAIL_START_ROW + $index;
            $sheet->setCellValue("A{$row}", $index + 1);          // No.
            $sheet->setCellValue("B{$row}", $item['name']);        // 商品名
            $sheet->setCellValue("C{$row}", $item['quantity']);    // 数量
            $sheet->setCellValue("D{$row}", $item['unitPrice']);   // 単価
            // E列(小計)はテンプレート側の数式 =C*D をそのまま使う
        }

        return $spreadsheet;
    }

    private function guardRowCount(OrderReportData $data): void
    {
        $count = count($data->items);
        if ($count > self::MAX_DETAIL_ROWS) {
            throw new \OverflowException(
                "明細行数が上限を超えています: {$count} 行(上限 " . self::MAX_DETAIL_ROWS . " 行)"
            );
        }
    }
}

ポイントをまとめます。

  • E 列(小計)はテンプレート側の数式をそのまま使う — PHP 側で計算値を上書きしません。テンプレートに持たせた =C7*D7 のような数式が活きるため、値と計算ロジックを二重管理しなくて済みます
  • 行数超過は例外で止める — 明細が 5 行を超えた場合、黙って切り捨てると業務帳票では事故になります。OverflowException で明示的に止めることで、呼び出し側が「テンプレートの行数を増やす」か「行挿入方式に切り替える」かを判断できます
  • PHP 側は入力セルだけ触る — テンプレート側で持っている罫線や数式を活かすなら、PHP 側の責務を値の設定だけに絞るほうが保守しやすくなります

詰まったら

  • 行位置がずれた場合はテンプレート側の開始行(6 行目が見出し、7 行目が明細開始)とコード側の DETAIL_START_ROW を見直す
  • OverflowException が出た場合は「テンプレートの明細行数を増やす」か「行挿入方式へ進む」の 2 択になります

コードのポイント

① ガードを先に呼び、テンプレートは後で開く

        $this->guardRowCount($data);

        $spreadsheet = IOFactory::load(self::TEMPLATE_PATH);

guardRowCount()IOFactory::load() より前に呼ぶことで、行数オーバーの場合はテンプレートを開かずに即座に例外を投げる。重いファイル I/O の前に入力検証を終わらせるのが基本的な順序。

② 行アドレスを計算して A〜D 列に値を入れ、E 列は触らない

        foreach ($data->items as $index => $item) {
            $row = self::DETAIL_START_ROW + $index;
            $sheet->setCellValue("A{$row}", $index + 1);
            $sheet->setCellValue("B{$row}", $item['name']);
            $sheet->setCellValue("C{$row}", $item['quantity']);
            $sheet->setCellValue("D{$row}", $item['unitPrice']);
            // E列(小計)はテンプレート側の数式 =C*D をそのまま使う
        }

$row = self::DETAIL_START_ROW + $index で行番号を計算する。E 列(小計)はテンプレートに埋め込んだ =C*D 数式が自動計算するため PHP 側では書き込まない。ここを上書きすると数式が値に置き換わり、Excel 上で数式が消える。

guardRowCount() は黙って切り捨てず例外で止める

    private function guardRowCount(OrderReportData $data): void
    {
        $count = count($data->items);
        if ($count > self::MAX_DETAIL_ROWS) {
            throw new \OverflowException(
                "明細行数が上限を超えています: {$count} 行(上限 " . self::MAX_DETAIL_ROWS . " 行)"
            );
        }
    }

count($data->items)MAX_DETAIL_ROWS を比較するだけのシンプルな番人。黙って切り捨てると業務帳票では気づかずに行が欠けるため、\OverflowException で止めることで呼び出し側に「テンプレートの行数を増やす」か「行挿入方式へ切り替える」かを判断させる。

6. ブラウザから .xlsx をダウンロードできるようにする

HTTP レスポンスとして帳票を返し、ブラウザからダウンロードできるようにします。

6-1. トップページを作る

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

<?php

declare(strict_types=1);

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

6-2. ダウンロード処理を作る

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

<?php

declare(strict_types=1);

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

use App\ExcelReportBuilder;
use App\OrderReportData;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;

$data = OrderReportData::sample();
$builder = new ExcelReportBuilder();
$spreadsheet = $builder->build($data);

$filename = 'order-report-' . date('Ymd') . '.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 の前に空行を入れない、?> の閉じタグは省略する、といった基本に注意してください。

php://output で直接出力する構成が最もシンプルです。ただし、PhpSpreadsheet は php://output への書き込み時にテンポラリファイルを経由します。機微情報を含む帳票では、テンポラリ領域のパスや権限も別途考慮してください。

処理後に disconnectWorksheets() を呼んでおくと、Spreadsheet オブジェクトの循環参照が解消され、メモリが適切に解放されます。

6-3. 開発サーバーを起動する

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

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

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

コードのポイント

Content-Type ヘッダと php://output への書き込み

$filename = 'order-report-' . date('Ymd') . '.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();

Content-Type.xlsx の MIME type を指定し、Content-Disposition: attachment でブラウザにダウンロードを強制する。save('php://output') で HTTP レスポンスボディへ直接書き込む。header() より前に何かを出力していると「headers already sent」になりファイルが壊れる。

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

7-1. ダウンロード確認

  1. ブラウザで http://localhost:8080 を開き、「受注明細書をダウンロード」をクリックする
  2. ダウンロードされた .xlsx ファイルを Excel または LibreOffice Calc で開く
  3. 次の内容を確認する
    • 発行日(B3)と宛先(B4)にサンプルデータの値が入っている
    • 明細行(7〜9 行目)に商品名・数量・単価が入っている
    • 小計列(E7〜E9)がテンプレートの数式で計算されている
    • 合計セル(E12)が明細の小計を合算している
    • 見出し行の罫線やフォント太字がテンプレートどおり維持されている
ダウンロードした帳票の確認画面

確認は「ダウンロードできたか」ではなく「開いてレイアウトと値が正しいか」まで見ます。

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

curl -I http://localhost:8080/download.php

Content-Typeapplication/vnd.openxmlformats-officedocument.spreadsheetml.sheet になっていれば OK です。

curlで確認したレスポンスヘッダ

7-2. 詰まりどころ一覧

症状原因対処
ダウンロードしたファイルが Excel で開けない(破損)header() の前に echo・空白・BOM が混入しているdownload.phprequire 先のファイルで余計な出力がないか確認する。<?php の前に空行がないか、?> を省略しているかも見直す
Could not open "..." for readingテンプレートが未生成、またはパスが間違っているtemplates/order-report-template.xlsx が存在するか確認し、なければ 3 章の生成コマンドを再実行する
OverflowException: 明細行数が上限を超えています明細データがテンプレートの 5 行を超えているテンプレートの明細行数を増やすか、行挿入方式に切り替える
ダウンロードされず画面に文字化けが表示されるContent-Type ヘッダが設定される前に出力が始まっているheader() 呼び出しより前にある処理を見直す

php://output は手軽ですが、PhpSpreadsheet がテンポラリファイルを経由して書き込む点に注意してください。機微帳票を扱う本番環境では、テンポラリ領域のパスや OS の権限設計も別途検討が必要です。

8. まとめと次の一歩

次の流れで Excel 帳票のテンプレート方式出力を作りました。

  1. 再現用テンプレートを生成 — 見出し・罫線・数式をテンプレート側に持たせた
  2. テンプレートを読み込んで値を埋めたIOFactory::load() で既存 .xlsx を読み、PHP 側はセル値の設定だけに集中した
  3. ブラウザからダウンロードXlsx writer と php://output.xlsx を HTTP レスポンスとして返した

テンプレート方式は、次の条件に当てはまる帳票に向いています。

  • レイアウトが固定されている
  • 明細行数があらかじめ決まっている(または上限が小さい)
  • 罫線・フォント・数式をテンプレート側で管理したい

逆に、明細行数が可変で大きくなる帳票では行挿入とスタイル複製が必要になり、テンプレート方式だけでは対応しきれません。

次に進むなら、以下の方向が考えられます。

  • HTTP ハンドラへ組み込む — Slim 4 などのフレームワークでルーティングやミドルウェアと統合する
  • DB の実データで差し込む — PDO や Doctrine DBAL でデータを取得し、帳票データに変換する
  • 固定行を超える帳票を扱う — 行挿入方式でスタイルを複製しながら動的に行を追加する