1. 今回の学習ゴールと練習課題(仕様)
文法書で foreach や if の使い方を理解しても、実際のWebサービスで求められるデータ構造を前にすると「どうループを回して、どこで合計値を加算すればいいのか」が曖昧になりがちです。
そこで今回のドリルでは、ECサイトの決済・注文確認画面で必ず実装することになる「ショッピングカートの金額計算ロジック」を作成します。単なる四則演算だけでなく、実務で必須となる「標準税率(10%)」と「軽減税率(8%)」の税率別集計に挑戦してみましょう。
問題の入力データ
次のように、商品ごとの「商品名(name)」「税抜単価(price)」「数量(quantity)」「税区分(tax_type)」を保持した連想配列のリストが与えられます。
$cartItems = [
[
'name' => '特選ドリップコーヒー豆(200g)',
'price' => 1200,
'quantity' => 2,
'tax_type' => 'reduced', // 飲食料品: 軽減税率 8%
],
[
'name' => '有田焼オリジナルマグカップ',
'price' => 2400,
'quantity' => 1,
'tax_type' => 'standard', // 雑貨: 標準税率 10%
],
[
'name' => 'ミネラルウォーター(500ml)',
'price' => 110,
'quantity' => 6,
'tax_type' => 'reduced', // 飲食料品: 軽減税率 8%
],
[
'name' => 'コーヒードリッパー&サーバーセット',
'price' => 3800,
'quantity' => 1,
'tax_type' => 'standard', // 雑貨: 標準税率 10%
],
];
満たすべき要件仕様
- 小計の算出: 各商品の小計(税抜単価 × 数量)を正しく計算すること。
- 税率別の税抜合計を集計: 税区分が
'reduced'の商品は「軽減税率(8%)対象」、'standard'の商品は「標準税率(10%)対象」として、それぞれの税抜合計額を合算すること。 - 消費税額の計算(端数切り捨て): 実務(日本のインボイス制度)に準拠し、「商品ごと」ではなく「税率別の税抜合計額」に対して税率(8% / 10%)を掛け合わせ、小数点以下は切り捨て(
floor)とすること。 - 総合計金額の算出: 「税抜総合計」と「消費税合計」を合算した「税込総合計金額」を求めること。
- 結果の出力: 計算結果を整理されたレシート形式で標準出力に表示すること。
期待される実行結果
--------------------------------------------------
【お買い上げ明細】
・特選ドリップコーヒー豆(200g) x 2点 : ¥2,400 (軽減税率 8%)
・有田焼オリジナルマグカップ x 1点 : ¥2,400 (標準税率 10%)
・ミネラルウォーター(500ml) x 6点 : ¥660 (軽減税率 8%)
・コーヒードリッパー&サーバーセット x 1点 : ¥3,800 (標準税率 10%)
--------------------------------------------------
税抜総合計: ¥9,260
消費税 (10%対象 / 税抜: ¥6,200): ¥620
消費税 (8%対象 / 税抜: ¥3,060): ¥244
消費税合計: ¥864
税込総合計: ¥10,124
--------------------------------------------------
まずはここで一旦画面を止め、ご自身の手元のエディタ(または php -a インタラクティブシェル)でコードを書いてみてください!
2. 初心者がハマるNGコード(Before)と解説
初学者のコードレビューをしていると、動作はしているように見えても「現場では絶対にリジェクトされる致命的な問題」を抱えた実装が頻出します。典型的なNGコードを見てみましょう。
<?php
// ❌ 初心者がやりがちなNGコード例
function calc($cart) {
$total = 0;
$tax = 0;
foreach ($cart as $item) {
// NG①: キーの存在確認や型チェックをせず直接アクセス
$subtotal = $item['price'] * $item['quantity'];
$total += $subtotal;
// NG②: 緩い比較(==)と商品単位の消費税計算(端数ズレの原因)
if ($item['tax_type'] == 'reduced') {
$tax += floor($subtotal * 0.08);
} else if ($item['tax_type'] == 'standard') {
$tax += floor($subtotal * 0.10);
}
echo $item['name'] . ": " . $subtotal . "円\n";
}
echo "合計: " . ($total + $tax) . "円\n";
}
?>
なぜこのコードは現場でNGなのか?
- 落とし穴①:インボイス制度に反する「商品単位の端数切り捨て」
このコード最大の間違いは、ループの中で商品ごとにfloor($subtotal * 0.08)を行っている点です。たとえば110円の商品(8%税額は8.8円 → 切り捨てて8円)を複数回別々に計算すると、税抜合計に対して一度だけ税率を掛けた場合の金額と「数円のズレ」が発生します。現行の日本の消費税法(インボイス制度)では、「1つのインボイスにつき、税率ごとに1回の端数処理」と定められており、商品ごとに端数処理をすると経理上NGになります。 - 落とし穴②:未定義キーへの無防備なアクセス(Warningの温床)
もし配列の中にquantityやtax_typeが欠落したデータが1件でも混ざると、PHP 8系では即座にWarning: Undefined array key ...が発生します。実務ではフォーム送信や外部APIから渡されるデータに欠損がある可能性を常に想定しなければなりません。 - 落とし穴③:文字列の緩い比較(
==)と想定外値の未考慮
$item['tax_type'] == 'reduced'と緩い比較を行うと予期せぬ型変換事故の原因になります。また、'reduced'でも'standard'でもない不正な文字列(例: タイポで'reduce')が入っていた場合に、何のエラーも出ず税金が0円としてすり抜けてしまいます。 - 落とし穴④:計算ロジックと画面出力の密結合
関数calc()の中で計算を行いながら同時にechoしています。これでは「合計金額だけをDBに保存したい」「JSONとしてAPIで返却したい」といった場合に一切再利用できません。「計算する関数」と「表示する処理」は明確に分けるのが鉄則です。
3. コピペで即動く模範解答コード(After)
それでは、PHP 8.3/8.4基準のモダンで安全な模範解答コードを確認しましょう。ファイル名 cart_drill.php として保存し、ターミナルで php cart_drill.php を実行すればそのまま動きます。
<?php
declare(strict_types=1);
/**
* カート内の商品一覧から金額および税率別消費税を計算する
*
* @param array<int, array{name: string, price: int, quantity: int, tax_type: string}> $items
* @return array{
* items: array<int, array{name: string, subtotal: int, quantity: int, tax_rate_percent: int}>,
* subtotal: int,
* subtotal_by_rate: array<int, int>,
* tax_by_rate: array<int, int>,
* total_tax: int,
* grand_total: int
* }
*/
function calculateCart(array $items): array
{
$detailedItems = [];
$subtotalByRate = [
10 => 0, // 標準税率 10%
8 => 0, // 軽減税率 8%
];
foreach ($items as $index => $item) {
// 必須キーの存在チェックと型整合性の担保
$name = (string)($item['name'] ?? "商品#{$index}");
$price = (int)($item['price'] ?? 0);
$quantity = (int)($item['quantity'] ?? 0);
$taxType = (string)($item['tax_type'] ?? 'standard');
if ($price < 0 || $quantity <= 0) {
throw new InvalidArgumentException("商品「{$name}」の単価または数量が不正です。");
}
// match式で税率を判定(未知の値は例外で弾く)
$taxRatePercent = match ($taxType) {
'standard' => 10,
'reduced' => 8,
default => throw new InvalidArgumentException("未知の税区分です: {$taxType}"),
};
// 商品ごとの税抜小計
$subtotal = $price * $quantity;
// 税率別の税抜合計に加算
$subtotalByRate[$taxRatePercent] += $subtotal;
$detailedItems[] = [
'name' => $name,
'quantity' => $quantity,
'subtotal' => $subtotal,
'tax_rate_percent' => $taxRatePercent,
];
}
// インボイス制度に則り「税率ごとの税抜合計」に対して消費税を計算(切り捨て)
$taxByRate = [
10 => (int)floor($subtotalByRate[10] * 0.10),
8 => (int)floor($subtotalByRate[8] * 0.08),
];
$totalSubtotal = array_sum($subtotalByRate);
$totalTax = array_sum($taxByRate);
$grandTotal = $totalSubtotal + $totalTax;
return [
'items' => $detailedItems,
'subtotal' => $totalSubtotal,
'subtotal_by_rate' => $subtotalByRate,
'tax_by_rate' => $taxByRate,
'total_tax' => $totalTax,
'grand_total' => $grandTotal,
];
}
/**
* レシート形式で計算結果を標準出力に整形表示する
*/
function printReceipt(array $result): void
{
echo "--------------------------------------------------\n";
echo "【お買い上げ明細】\n";
foreach ($result['items'] as $item) {
$taxLabel = ($item['tax_rate_percent'] === 8) ? '軽減税率 8%' : '標準税率 10%';
$formattedPrice = number_format($item['subtotal']);
echo "・{$item['name']} x {$item['quantity']}点 : ¥{$formattedPrice} ({$taxLabel})\n";
}
echo "--------------------------------------------------\n";
echo "税抜総合計: ¥" . number_format($result['subtotal']) . "\n";
echo "消費税 (10%対象 / 税抜: ¥" . number_format($result['subtotal_by_rate'][10]) . "): ¥" . number_format($result['tax_by_rate'][10]) . "\n";
echo "消費税 (8%対象 / 税抜: ¥" . number_format($result['subtotal_by_rate'][8]) . "): ¥" . number_format($result['tax_by_rate'][8]) . "\n";
echo "消費税合計: ¥" . number_format($result['total_tax']) . "\n";
echo "税込総合計: ¥" . number_format($result['grand_total']) . "\n";
echo "--------------------------------------------------\n";
}
// --- 動作検証コード ---
$cartItems = [
[
'name' => '特選ドリップコーヒー豆(200g)',
'price' => 1200,
'quantity' => 2,
'tax_type' => 'reduced',
],
[
'name' => '有田焼オリジナルマグカップ',
'price' => 2400,
'quantity' => 1,
'tax_type' => 'standard',
],
[
'name' => 'ミネラルウォーター(500ml)',
'price' => 110,
'quantity' => 6,
'tax_type' => 'reduced',
],
[
'name' => 'コーヒードリッパー&サーバーセット',
'price' => 3800,
'quantity' => 1,
'tax_type' => 'standard',
],
];
try {
$calcResult = calculateCart($cartItems);
printReceipt($calcResult);
} catch (InvalidArgumentException $e) {
echo "エラーが発生しました: " . $e->getMessage() . "\n";
}
4. 現場のプロ(kixking)による徹底コード解説
この模範解答には、実務の開発現場で「お、この人は基礎がしっかりしてるな!」と評価されるポイントが凝縮されています。重要な設計意図を1つずつ紐解いていきましょう。
ポイント①:厳格な型モード declare(strict_types=1); の宣言
PHPファイルの冒頭に必ず書くべきお守りです。デフォルトのPHPは型の暗黙変換が緩やかですが、strict_types=1 を宣言することで、関数の引数や戻り値の型不一致を即座にエラーとして検出し、バグを未然に防ぐことができます。
ポイント②:PHP 8の目玉「match式」による安全な分岐
従来の switch 文や多重の if-elseif 文と違い、PHP 8で導入された match 式には以下の絶大なメリットがあります:
- 厳密比較(
===相当)が行われる: 数値の0と文字列"0"、あるいはnullの誤判定事故を防ぎます。 - 戻り値を直接変数に代入できる:
break忘れによるフォールスルー(バグの原因No.1)が原理的に発生しません。 - 網羅性の担保(UnhandledMatchError): 想定外の文字列が渡された場合、
default句を書いていなければPHPエンジンが例外をスローしてくれます(今回は明示的にInvalidArgumentExceptionを投げて親切にエラー化しています)。
ポイント③:Null合体演算子(??)による防御的プログラミング
$price = (int)($item['price'] ?? 0); のように Null 合体演算子を使うことで、連想配列にキーが存在しない場合でも警告(Warning)を出さずにフォールバック値を取得できます。さらに直後で $price < 0 || $quantity <= 0 のバリデーションを設けることで、不正なデータによる「負の金額計算」を堅牢にブロックしています。
ポイント④:単一責任の原則(計算ロジックと画面表示の完全分離)
模範解答では、「計算を行って連想配列を返す純粋な関数 calculateCart()」と、「結果を受け取って文字列として整形・出力する関数 printReceipt()」に役割を綺麗に分離しています。
「計算結果を画面に出力する」と「計算結果をDBに保存する / APIで返す」は全く別の関心事です。関数の中に echo を混ぜない癖をつけるだけで、コードのテスト容易性(Unit Test)と再利用性は飛躍的に跳ね上がります。
ポイント⑤:インボイス制度の端数処理ルールを関数で再現
各商品ループ内では $subtotalByRate[$taxRatePercent] += $subtotal; と税抜金額の合算のみを行い、ループ終了後に一度だけ floor($subtotalByRate[10] * 0.10) を計算しています。この構造を理解して書けるかどうかが、初心者と実務経験者の決定的な分水嶺となります。
5. もう一歩進める!発展チャレンジ課題
模範解答が動いたら、コードをさらに改造してレベルアップを図りましょう!以下の3つの発展課題にぜひ挑戦してみてください。
発展課題A:全体割引クーポン(値引き)の適用ロジック
「カート全体から1,000円引きするクーポン」を適用する場合、10%対象商品と8%対象商品のどちらからいくら値引くべきでしょうか?
- ヒント: 実務では「税抜合計金額の比率に応じて値引き額を按分(あんぶん)する」か、「標準税率(10%)対象から優先して値引きする」ルールを定めます。按分計算を行い、端数が出ないよう調整するロジックを関数に追加してみましょう。
発展課題B:オブジェクト指向への昇格(readonly class の導入)
連想配列 $item['name'] をそのまま引き回す代わりに、PHP 8.2以降で使える readonly class CartItem を定義してみてください。
readonly class CartItem
{
public function __construct(
public string $name,
public int $price,
public int $quantity,
public string $taxType = 'standard',
) {}
public function getSubtotal(): int
{
return $this->price * $this->quantity;
}
}
配列からクラスのオブジェクトに置き換えることで、エディタの補完が効くようになり、型安全性が劇的に向上します。
発展課題C:自動テスト(PHPUnit または Pest)を書いてみる
単価が0円の場合、軽減税率のみの場合、標準税率のみの場合など、境界値を含む複数のパターンのテストケースを作成し、calculateCart() が常に正しい計算結果を返すか検証してみましょう。
手を動かして書いたコードの行数だけ、確実にあなたの血肉になります。まずは今回の模範コードを1行ずつ写経し、値を変えて挙動を確認するところから始めてみてくださいね!