1. 今回の学習ゴールと練習課題(仕様)

Webアプリケーションの開発現場において、Excelや外部システムから書き出された「CSVファイルのインポート・集計処理」は、お問い合わせフォームや認証機能と並ぶ頻出タスクです。

しかし、「ネットの古い記事を真似して file_get_contents() と explode(',', $line) で分割したら商品名の中のカンマで列が崩壊した」「金額に "7,500" と入っていて計算結果が 7 になってしまった」という悲鳴が後を絶ちません。

そこで今回のドリルでは、外部データベースや複雑なライブラリを使わず、「PHP 8.3/8.4基準の単一スクリプト(CLI)だけで完結して動かせる売上CSV集計クラス」を作成します。

満たすべき要件仕様

  1. 入力CSVのデータ仕様:
    • カラム構成(7列): 注文ID, 注文日, 顧客名, 商品名, 単価, 数量, ステータス
    • 商品名にカンマが含まれる場合(例: "クラシックロゴTシャツ, ホワイト")でも、正しく1つの商品名としてパースできること。
    • 単価に3桁カンマ区切りが含まれる場合(例: "7,500")でも、安全に数値型(7500)へ変換して計算できること。
  2. スキップ&バリデーション仕様:
    • 1行目のヘッダー行: 集計対象外として自動スキップすること。
    • ステータスが cancelled の行: キャンセル注文として売上計算・注文数から除外すること(エラー行扱いにはしない)。
    • カラム数不足の行: カンマが足りない壊れた行はエラー行としてスキップし、行番号とエラー理由を記録すること。
    • 不正な単価・数量: 数量が0以下または数値以外の行、単価が数値以外の行はエラー行としてスキップし、記録すること。
  3. 集計と出力仕様:
    • 有効な注文数(件数) と 総売上金額(小計 = 単価 × 数量 の合算) を算出すること。
    • スキップされたエラー行数 と、その行番号・詳細理由を一覧表示すること。
    • 商品別の販売個数・売上金額 を連想配列で集計し、売上金額の降順でランキング出力すること。

期待される実行結果

スクリプトを実行した際、以下のようにサマリー、スキップされたエラー詳細、商品別ランキングが明瞭に出力されることを目指します。

=== 集計サマリー ===
有効注文数: 4 件
総売上金額: 19,500 円
スキップ行数: 2 件

=== スキップ詳細(エラー) ===
・行 4: 数量が1以上の正の整数ではありません: "0"
・行 7: カラム数が不足しています(期待: 7列、実際: 5列)

=== 商品別売上ランキング ===
・クラシックロゴTシャツ, ホワイト : 3点 / 8,400円
・デニムジーンズ : 1点 / 7,500円
・ソックス(3足組) : 3点 / 3,600円

まずはここで画面をスクロールするのを止め、ご自身のエディタでどのように関数やクラスを組み立てるか考えてみてください!

2. 初心者がハマるNGコード(Before)と解説

CSV処理を実装しようとしたとき、初学者がほぼ100%ハマってしまう典型的なNGコードを見てみましょう。

<?php
// ❌ 初心者がやりがちな致命的NGコード例

function aggregateSalesBad(string $csvFilePath): array
{
    // NG①: ファイル全体を丸ごと文字列として読み込む(メモリ爆発の危険)
    $content = file_get_contents($csvFilePath);
    
    // NG②: 改行とカンマを explode() で機械的に分割する
    $lines = explode("\n", $content);
    
    $total = 0;
    
    foreach ($lines as $index => $line) {
        if ($index === 0) continue; // ヘッダー
        if (trim($line) === '') continue;

        // 致命的NG: 商品名にカンマがあるとカラム数が狂って大事故になる!
        $columns = explode(',', $line);
        
        $price = (int)$columns[4];    // NG③: "7,500" を (int) キャストすると 7 になる!
        $quantity = (int)$columns[5];
        
        $total += $price * $quantity;
    }

    return ['total' => $total];
}
?>

なぜこの書き方は現場で絶対にリジェクトされるのか?

3. コピペで即動く模範解答コード(After)

それでは、PHP 8.3/8.4基準のモダンで安全な模範解答コードを作成しましょう。このコードを csv_drill.php という名前で保存し、ターミナルで php csv_drill.php と実行するだけで即座にテスト結果が動きます。

<?php

declare(strict_types=1);

/**
 * 売上明細CSV集計クラス
 * ストリームから1行ずつ読み込み、安全な型変換・バリデーション・集計を行う
 */
final class SalesCsvProcessor
{
    /** @var array<int, array{row: int, reason: string, rawData: array<int, string|null>}> */
    private array $errors = [];

    private int $totalAmount = 0;
    private int $validOrderCount = 0;

    /** @var array<string, array{quantity: int, sales: int}> */
    private array $itemSummary = [];

    /**
     * CSVストリームリソースを処理して集計結果を返す
     *
     * @param resource $stream
     * @return array{
     *     totalAmount: int,
     *     validOrderCount: int,
     *     skippedCount: int,
     *     itemSummary: array<string, array{quantity: int, sales: int}>,
     *     errors: array<int, array{row: int, reason: string, rawData: array<int, string|null>}>
     * }
     */
    public function process($stream): array
    {
        if (!is_resource($stream)) {
            throw new InvalidArgumentException('有効なストリームリソースを指定してください。');
        }

        $rowNumber = 0;

        // fgetcsv に escape: '' を指定して RFC 4180 に完全準拠したパースを実施(メモリ消費を抑え1行ずつ処理)
        while (($row = fgetcsv($stream, escape: '')) !== false) {
            $rowNumber++;

            // 1行目はヘッダー行としてスキップ
            if ($rowNumber === 1) {
                continue;
            }

            // 空行(改行のみの行など)をスキップ
            if ($row === [null] || empty(array_filter($row, fn($col) => $col !== null && trim((string)$col) !== ''))) {
                continue;
            }

            $this->processRow($rowNumber, $row);
        }

        return [
            'totalAmount'     => $this->totalAmount,
            'validOrderCount' => $this->validOrderCount,
            'skippedCount'    => count($this->errors),
            'itemSummary'     => $this->itemSummary,
            'errors'          => $this->errors,
        ];
    }

    /**
     * 各行のバリデーションと集計処理
     * 列定義: [0:注文ID, 1:注文日, 2:顧客名, 3:商品名, 4:単価, 5:数量, 6:ステータス]
     *
     * @param int $rowNumber
     * @param array<int, string|null> $row
     */
    private function processRow(int $rowNumber, array $row): void
    {
        // 1. カラム数チェック(期待値: 7列)
        if (count($row) < 7) {
            $this->errors[] = [
                'row'     => $rowNumber,
                'reason'  => 'カラム数が不足しています(期待: 7列、実際: ' . count($row) . '列)',
                'rawData' => $row,
            ];
            return;
        }

        [$orderId, $orderDate, $customerName, $itemName, $rawPrice, $rawQuantity, $status] = $row;

        // 2. ステータス判定(cancelled は仕様として集計から除外)
        $status = trim((string)$status);
        if ($status === 'cancelled') {
            return;
        }

        // 3. 単価の書式検証と数値化(正規表現でカンマなし整数または3桁区切りカンマ形式をチェック)
        $trimmedPrice = trim((string)$rawPrice);
        if (!preg_match('/^(?:[0-9]+|[0-9]{1,3}(?:,[0-9]{3})+)$/', $trimmedPrice)) {
            $this->errors[] = [
                'row'     => $rowNumber,
                'reason'  => "単価の書式が無効です: \"{$rawPrice}\"",
                'rawData' => $row,
            ];
            return;
        }
        $cleanPriceStr = str_replace(',', '', $trimmedPrice);
        // オーバーフロー防止の上限値ガード(9桁・10億円未満)
        if (strlen($cleanPriceStr) > 9) {
            $this->errors[] = [
                'row'     => $rowNumber,
                'reason'  => "単価が許容上限(9桁以内)を超えています: \"{$rawPrice}\"",
                'rawData' => $row,
            ];
            return;
        }
        $price = (int)$cleanPriceStr;

        // 4. 数量の書式検証と数値化(1以上の正の整数、上限7桁)
        $trimmedQuantity = trim((string)$rawQuantity);
        if (!preg_match('/^(?:[0-9]+|[0-9]{1,3}(?:,[0-9]{3})+)$/', $trimmedQuantity)) {
            $this->errors[] = [
                'row'     => $rowNumber,
                'reason'  => "数量の書式が無効です: \"{$rawQuantity}\"",
                'rawData' => $row,
            ];
            return;
        }
        $cleanQuantityStr = str_replace(',', '', $trimmedQuantity);
        if (strlen($cleanQuantityStr) > 7) {
            $this->errors[] = [
                'row'     => $rowNumber,
                'reason'  => "数量が許容上限(7桁以内)を超えています: \"{$rawQuantity}\"",
                'rawData' => $row,
            ];
            return;
        }
        $quantity = (int)$cleanQuantityStr;
        if ($quantity <= 0) {
            $this->errors[] = [
                'row'     => $rowNumber,
                'reason'  => "数量が1以上の正の整数ではありません: \"{$rawQuantity}\"",
                'rawData' => $row,
            ];
            return;
        }

        // 5. 商品名の存在チェック
        $itemName = trim((string)$itemName);
        if ($itemName === '') {
            $this->errors[] = [
                'row'     => $rowNumber,
                'reason'  => '商品名が空です',
                'rawData' => $row,
            ];
            return;
        }

        // 6. 有効データの売上集計
        $subtotal = $price * $quantity;
        $this->totalAmount += $subtotal;
        $this->validOrderCount++;

        // 商品別ランキング用の集計
        if (!isset($this->itemSummary[$itemName])) {
            $this->itemSummary[$itemName] = ['quantity' => 0, 'sales' => 0];
        }
        $this->itemSummary[$itemName]['quantity'] += $quantity;
        $this->itemSummary[$itemName]['sales']    += $subtotal;
    }
}

// ==========================================
// 動作確認テストコード(コピペで即実行可能)
// ==========================================

// テスト用売上明細CSV(カンマ入り商品名、カンマ入り数値、キャンセル、壊れた行を含む)
$csvData = <<<CSV
order_id,order_date,customer_name,item_name,price,quantity,status
ORD-001,2026-10-01,田中太郎,"クラシックロゴTシャツ, ホワイト",2800,2,completed
ORD-002,2026-10-01,佐藤花子,デニムジーンズ,"7,500",1,completed
ORD-003,2026-10-02,鈴木一郎,スニーカー,9800,0,completed
ORD-004,2026-10-02,高橋次郎,"プレミアムパーカ, ブラック (L)","6,200",1,cancelled
ORD-005,2026-10-03,伊藤美咲,ソックス(3足組),1200,3,completed
ORD-006,2026-10-03,渡辺健太,壊れた行データ,3000
ORD-007,2026-10-04,小林直樹,"クラシックロゴTシャツ, ホワイト",2800,1,completed
CSV;

// メモリ上に仮想ストリームを作成してCSVを流し込む
$stream = fopen('php://memory', 'r+');
fwrite($stream, $csvData);
rewind($stream); // ポインタを先頭に戻す

// 集計プロセッサの実行
$processor = new SalesCsvProcessor();
$result = $processor->process($stream);
fclose($stream);

// 結果の出力表示
echo "=== 集計サマリー ===\n";
echo "有効注文数: " . $result['validOrderCount'] . " 件\n";
echo "総売上金額: " . number_format($result['totalAmount']) . " 円\n";
echo "スキップ行数: " . $result['skippedCount'] . " 件\n\n";

echo "=== スキップ詳細(エラー) ===\n";
foreach ($result['errors'] as $err) {
    echo "・行 " . $err['row'] . ": " . $err['reason'] . "\n";
}

echo "\n=== 商品別売上ランキング ===\n";
// 売上金額の高い順に降順ソート
uasort($result['itemSummary'], fn($a, $b) => $b['sales'] <=> $a['sales']);

foreach ($result['itemSummary'] as $item => $data) {
    echo "・" . $item . " : " . $data['quantity'] . "点 / " . number_format($data['sales']) . "円\n";
}

4. 現場のプロ(kixking)による徹底コード解説

この模範解答コードには、Web開発の実務現場で「障害を起こさない」「レビューで突き返されない」ためのエッセンスが凝縮されています。重要なポイントを1つずつ紐解いていきましょう。

① なぜ fgetcsv() なのか?RFC 4180準拠と escape: '' の真実

PHP標準の fgetcsv() は、高速なC言語レベルのCSVパーサーです。
二重引用符 " で囲まれたフィールドを認識し、その中にあるカンマや改行文字を「区切り文字」ではなく「セル内の文字列」として正しく保護してくれます。今回のテストデータにある "クラシックロゴTシャツ, ホワイト" も、explode() では破壊されてしまいますが、fgetcsv() なら正確に1つの要素として配列に格納されます。

【超重要】なぜ escape: ''(空文字)の指定が必須なのか?
「fgetcsv() は RFC 4180 準拠」とよく言われますが、実はエスケープ文字が有効なままだと完全には準拠しません。
PHPの fgetcsv() は歴史的な経緯からバックスラッシュ(\)を独自エスケープ文字として解釈する仕様(デフォルト escape: '\\')を持っています。しかし、RFC 4180 ではダブルクォート内の二重引用符は "" で表現され、バックスラッシュによるエスケープは存在しません。
そのため、もしセル内に \" が含まれていると、バックスラッシュが次の引用符をエスケープしてしまい、列の区切りが崩壊して3列が2列に合体してしまう等の実害が発生します。RFC 4180 に完全準拠させて安全に処理するには、エスケープを無効化する escape: '' の指定が不可欠です(PHP 8.4 でもデフォルトの \\ の使用は非推奨(Deprecated)となり、明示的に escape: '' を渡すことが強く推奨されています)。

② ストリーム設計によるメモリ効率と、エラー保持の現実的トレードオフ

模範コードでは、引数にファイル名(文字列)を直接受け取るのではなく、$stream(リソース)を受け取る設計にしています。

ファイルパスを受け取って内部で fopen($path, 'r') する場合でも同様ですが、while (($row = fgetcsv($stream, escape: '')) !== false) というループ構造をとることで、ファイル全体をメモリに載せず「1行読み込んでは集計し、変数を上書きしてメモリを即座に解放する」というストリーム処理が可能になります。正常データの読み込みと合算だけであれば、CSVが10行であっても100万行であってもメモリ使用量はほぼ一定に抑えられます。

実務での注意点(メモリ O(1) の限界):
ただし、模範コードのようにエラー行($this->errors)の生データを配列に無制限に溜め込む設計だと、100万行の大半が壊れていた場合にエラー配列が肥大化して結局メモリを圧迫します。実務で数十万行超の大規模CSVを処理する場合は、「エラー配列の保持は先頭100件までに制限し、以降はカウントのみ行う」または「エラーログファイルへ随時フラッシュして書き出す」といった上限設計を組み合わせるのが現場の鉄則です。

③ 日本のWeb現場の天敵「Shift_JIS(CP932)」とBOMの対策

日本のクライアントや経理部門がWindowsのExcelで書き出したCSVは、高確率で文字コードが Shift_JIS(正確にはWindows固有文字を含むCP932) になっています。これをUTF-8前提のPHPでそのまま読み込むと、日本語がすべて激しく文字化けします。

実務で実ファイル(sales.csv)を扱う場合は、以下のようにストリームフィルタを挟むのが最もスマートで高速です:

// Shift_JIS(CP932) のファイルを UTF-8 にリアルタイム変換しながらストリームオープン
$stream = fopen('sales_sjis.csv', 'r');
if ($stream !== false) {
    // ストリームフィルタを追加して自動変換
    stream_filter_append($stream, 'convert.iconv.CP932/UTF-8//TRANSLIT', STREAM_FILTER_READ);
    
    $processor = new SalesCsvProcessor();
    $result = $processor->process($stream);
    fclose($stream);
}

また、UTF-8で保存されたCSVの先頭に BOM(Byte Order Mark: \xEF\xBB\xBF) が付着している場合、1行目の最初のセル先頭に不可視のゼロ幅ノーブレークスペースが含まれてしまいます。もし「1行目のヘッダーが期待通りのカラム名か検証する」仕様や「ヘッダーなしCSVで1行目からデータが始まる」仕様の場合、見た目は同じに見えても "\u{FEFF}order_id" === "order_id" が false になり判定に失敗します。(※本ドリルの模範コードでは1行目を $rowNumber === 1 で無条件スキップしているためデータ行へのBOM影響を受けませんが、ヘッダー名をチェックする実務コードでは、先頭セルのBOM除去(preg_replace('/^\xEF\xBB\xBF/', '', $cell) など)を行うのが定番の配慮です)。

④ 金額・数量の書式検証と、安易な is_numeric() / str_replace の罠

金額や数量のバリデーションに is_numeric() を安易に使ってはいけません。is_numeric() は "1e3"(指数表記)や "+123"(符号付き)、"12.34"(小数)すら true と判定してしまうためです(※なお "0xFF" などの16進数文字列はPHP 5までは true でしたが、PHP 7以降は false に変更されています)。自然数を期待する場面で is_numeric() を使うと、意図しない小数の混入を許してしまいます。

また、単純に str_replace(',', '', $val) でカンマを消してから判定するだけでは、",,1" や "7,5,0,0"、"1,,000" といったフォーマット破壊文字列までカンマが消えて検査を通過してしまう穴が生じます。さらに、桁数が極端に大きい数値文字列を (int) キャストすると、64bit環境でも PHP_INT_MAX(9,223,372,036,854,775,807)にサチュレート(最大値に張り付き)して計算が狂うリスクもあります。

そのため模範解答では:

  1. 正規表現 /^(?:[0-9]+|[0-9]{1,3}(?:,[0-9]{3})+)$/ で「カンマなし整数、または正しい3桁カンマ区切り形式」であるかを厳密に検査する(",,1" や "7,5,0,0" を確実に排除)。
  2. 書式が正常なものだけカンマを除去し、桁数(単価は9桁以内・数量は7桁以内)をチェックして PHP_INT_MAX へのオーバーフロー張り付きを防止する。
  3. 安全が確認された数値のみを (int) キャストする。

というステップを踏んでいます。実務のデータ受取口では、「単にキャストできるか」だけでなく「業務仕様として許容される書式と上限範囲内か」まで踏み込んで検証することが不可欠です。

⑤ 業務で信頼される「耐障害性(Fault Tolerance)」

初心者のコードは「1箇所でもエラーがあると die() や例外で処理全体を止めてしまう」ことが多いです。
しかし、1万行の売上データのうち「たった1行だけ数量が空欄だった」という理由で、残りの9,999行の処理がすべて吹き飛んでしまったら業務になりません。

模範解答のように、「不正な行はスキップして行番号とエラー理由を配列にストックし、正常な行は最後まで集計し切る」という構造にすることで、管理者は後から「どこの行を直せばいいか」を即座に特定でき、業務が滞りません。

5. もう一歩進める!発展チャレンジ課題

模範解答が手元で動いたら、ぜひ以下の追加要件に自力で挑戦してみてください。CSV処理と配列操作の実力が一気にステップアップします!

チャレンジ1:集計サマリーを「UTF-8 BOM付きCSV」でエクスポートする

集計した $result['itemSummary'] のデータを、PHP標準の fputcsv() を使って新しいCSVファイル item_ranking.csv に書き出してみましょう。
Excelで開いたときに文字化けしないよう、ファイルの先頭にBOM(fwrite($fp, "\xEF\xBB\xBF");)を出力してからヘッダー行とデータ行を書き込むのが現場のプロの技です。

チャレンジ2:日付ごとの売上集計(日別サマリー)を追加する

現在は「商品別」の集計のみを行っていますが、2列目の order_date(例: 2026-10-01)をキーにして、日別の売上合計と注文件数 を集計するロジックを追加してみましょう。
多次元連想配列を活用し、日付ごとの売上推移を出力できるようにしてみてください。

チャレンジ3:Generator(yield)を使ったイテレータ化

今回はクラス内部で全行を集計していますが、数百万行のデータを1行ずつ検証しながら別のDBに INSERT していくようなユースケースを想定し、1行パースするごとに yield $row で値を返す「ジェネレータ関数」へリファクタリングしてみましょう。PHPのメモリ効率を極限まで高める技術が身につきます。