PHP 入門

クリーン、読みやすく、メンテナンス性の高い PHP コードを書く

本章では、あなたの PHP コードを、時間が経過しても理解しやすく、デバッグや修正が容易な状態に保つ方法に焦点を当てます。これらの原則に従うことで、チームのコラボレーション効率が大幅に向上し、コードのバグを減らし、アプリケーションのライフサイクルを延ばすことができます。

1. 一貫したフォーマットとインデントの維持

一貫したフォーマットはコードの可読性における基礎です。コードが予測可能な視覚的構造に従っているとき、読み手はロジックのフローをより簡単にスキャンし、理解することができます。インデント(字下げ)は、コードブロックの階層やネスト(入れ子)関係を視覚的に表現する上で、極めて重要な役割を果たします。

例えば、インデントが不規則だと、if 文や for ループのスコープ(適用範囲)を素早く判別することが難しくなり、開発やデバッグの段階でロジックエラーを引き起こしやすくなります。

<?php
// インデントが不規則な例(読みづらい)
function calculatePrice($quantity, $unitPrice, $taxRate) {
if ($quantity > 10) {
$discount = 0.10;
$totalPrice = ($quantity * $unitPrice * (1 - $discount));
} else {
$totalPrice = ($quantity * $unitPrice);
}
return $totalPrice * (1 + $taxRate);
} 

// インデントが一貫している例(クリアで読みやすい)
function calculatePriceConsistent($quantity, $unitPrice, $taxRate) {
    if ($quantity > 10) {
        $discount = 0.10;
        $totalPrice = ($quantity * $unitPrice * (1 - $discount));
    } else {
        $totalPrice = ($quantity * $unitPrice);
    }
    return $totalPrice * (1 + $taxRate);
} 

// フォーマットが一貫した関数の使用
echo "12個の商品価格: " . calculatePriceConsistent(12, 10, 0.05) . "\n";
echo "5個の商品価格: " . calculatePriceConsistent(5, 10, 0.05) . "\n";
?>

前の章で触れたように、PHP の PSR 標準(特に PSR-12)は、インデント(通常は4つのスペース)、1行の長さ、波括弧の位置など、PHP コードのフォーマットに関する広く受け入れられたガイドラインを提供しています。これらの標準に従うことで、異なるプロジェクトやチーム間でも高度な一貫性を保つことが可能になります。

2. 意味の明確な命名規則

変数、関数、クラス、および定数に付ける名前は、それらの用途や内容を明確かつ正確に記述したものでなければなりません。曖昧な名前や短すぎる名前は、コードを読む際の認知負荷を大幅に増大させます(作成者自身であっても、時間が経てば自分のコードの意味が分からなくなることがあります)。

アプリケーションでユーザーデータを管理しているシーンを想定してみましょう:

<?php
// 悪い命名規則の例
$u = "John Doe"; // 'u' は何を指すのか?
$db = "user_data"; // データベース接続、テーブル名、それとも実際のデータ?
function proc($d) { // 'proc' はプロセス? 'd' はデータ?あまりに広範すぎる
    // ...
} 

// 意味が明確な命名規則の例
$userName = "John Doe"; // ユーザー名であることが一目でわかる
$userDataTableName = "users"; // ユーザーを格納するテーブル名であることを示す
function processUserData(array $data) { // 関数の目的と引数のタイプが明確
    // ...
}
?>

意味のある命名は、コードの「自己文書化(Self-documentation)」能力を高め、基礎的な機能を説明するために大量のコメントを書く必要性を減らします。また、コードベース内で特定の要素を検索する際も容易になります。例えば、汎用的な $id よりも $userId を検索する方が、明らかに精度が高まります。

3. 慎重かつ合理的なコメントの使用

意味のある命名や整潔なコード構造によってコメントの必要性は減りますが、コメントは「なぜ(Why)」そのような決定を下したのか、複雑なアルゴリズムの解説、あるいは直感的でない挙動を強調する際に、依然として代えがたい価値を持ちます。コメントは、コードが既に表現している内容を単に繰り返すものであってはなりません。

複雑な計算ロジックや、特定のバグに対する一時的な解決策を想像してみてください:

<?php
function calculateDiscountedPrice(float $originalPrice, int $itemCount): float {
    $discountRate = 0;
    
    // 注文数が100個を超える場合にボリュームディスカウントを適用。
    // 具体的な閾値はマーケティング部のQ3キャンペーン戦略に基づいている。
    if ($itemCount >= 100) {
        $discountRate = 0.15; // 15% 割引
    } elseif ($itemCount >= 50) {
        $discountRate = 0.10; // 10% 割引
    }
    
    // プレミアム会員向けに価格を調整。
    // 注意:この調整はボリュームディスカウントの「後」に適用する必要がある。
    // これはレガシーシステム統合に起因する既知のエッジケースである。
    $finalPrice = $originalPrice * (1 - $discountRate);
    if (/* プレミアム会員チェック */ true) { // 実際のチェックロジックのプレースホルダー
        $finalPrice *= 0.95; // プレミアム会員はさらに 5% オフ
    }
    
    return $finalPrice;
} 

// 悪いコメントの例(見ればわかる内容)
$i = 0; // i を 0 に初期化

// 良いコメントの例(理由や複雑さを説明)
// このループは許容される最大リトライ回数まで繰り返される。
// 断続的な失敗が発生しやすいネットワークタイムアウト問題を処理するために特化している。
for ($i = 0; $i < MAX_RETRIES; $i++) {
    // ...
}
?>

コメントは簡潔に保ち、適時更新し、コードの字面以上の価値を提供しなければなりません。古くなったコメントや誤解を招くコメントは、将来の開発者を混乱させるため、コメントがないよりも悪影響を及ぼします。

4. マジックナンバーとマジックストリングの排除

「マジックナンバー(Magic numbers)」や「マジックストリング(Magic strings)」とは、コード内に直接ハードコードされ、その意味を説明するコンテキストが一切ないリテラル値を指します。これらは意味が直感的ではなく、修正が必要になった際にプロジェクト全体から出現箇所をすべて探し出さなければならないため、メンテナンスを極めて困難にします。

より良い方法は、これらの値を名前付きの「定数(Constants)」として定義し、用途を明確化して一元管理することです。

<?php
// マジックナンバーを含む悪い例
function checkOrderStatus($statusId) {
    if ($statusId == 1) { // '1' はどういう意味か?
        echo "注文は保留中です。\n";
    } elseif ($statusId == 2) { // '2' はどういう意味か?
        echo "注文は処理済みです。\n";
    }
} 

function calculateShippingCost($weight) {
    // なぜ 0.5 なのか?単位は何か?
    $baseCost = $weight * 0.5;
    // 10 は何のための数値か?
    return $baseCost + 10;
} 

// 名前付き定数を使用した良い例
const ORDER_STATUS_PENDING = 1;
const ORDER_STATUS_PROCESSED = 2;
const SHIPPING_RATE_PER_KG = 0.50;
const SHIPPING_BASE_FEE = 10.00; 

function checkOrderStatusWithConstants($statusId) {
    if ($statusId == ORDER_STATUS_PENDING) {
        echo "注文は保留中です。\n";
    } elseif ($statusId == ORDER_STATUS_PROCESSED) {
        echo "注文は処理済みです。\n";
    }
} 

function calculateShippingCostWithConstants($weight) {
    $baseCost = $weight * SHIPPING_RATE_PER_KG;
    return $baseCost + SHIPPING_BASE_FEE;
} 

// 使用方法
checkOrderStatusWithConstants(ORDER_STATUS_PENDING);
echo "5kgの送料: " . calculateShippingCostWithConstants(5) . "\n";
?>

定数を使用することで、コードが自己説明的になり、修正が容易になり(定数の値を1箇所変えるだけで済む)、手入力による不一致(タイポなど)を減らすことができます。

5. 肥大化した関数とクラスの分割

さまざまな異なるタスクを実行する「モノリス(巨大)」な関数やクラスは、読み取り、テスト、メンテナンスが非常に困難です。これらは「単一責任の原則(Single Responsibility Principle / SRP)」に違反しています。この原則は、1つのモジュール、クラス、または関数は、修正すべき理由が1つだけでなければならない(=1つのことだけを行うべき)と説いています。

肥大化したエンティティを、より小さく特化したユニットに分割することで、コードの可読性とメンテナンス性を大幅に向上させることができます。独立した小さなユニットは、それぞれ個別に理解・テストでき、再利用も容易になります。

ユーザー登録プロセスを担当する関数を見てみましょう:

<?php
// 肥大化した関数(悪い設計)
function handleUserRegistration($username, $email, $password) {
    // 1. 入力バリデーション
    if (empty($username) || empty($email) || empty($password)) {
        return ['success' => false, 'message' => 'すべてのフィールドが必須です。'];
    }
    if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
        return ['success' => false, 'message' => 'メール形式が無効です。'];
    }
    
    // 2. ユーザー名またはメールが既にデータベースに存在するかチェック
    global $db; 
    $stmt = $db->prepare("SELECT COUNT(*) FROM users WHERE username = ? OR email = ?");
    $stmt->execute([$username, $email]);
    if ($stmt->fetchColumn() > 0) {
        return ['success' => false, 'message' => 'ユーザー名またはメールが既に使用されています。'];
    }
    
    // 3. パスワードハッシュ化
    $hashedPassword = password_hash($password, PASSWORD_DEFAULT);
    
    // 4. ユーザーを保存
    $stmt = $db->prepare("INSERT INTO users (username, email, password) VALUES (?, ?, ?)");
    if (!$stmt->execute([$username, $email, $hashedPassword])) {
        return ['success' => false, 'message' => '登録に失敗しました。'];
    }
    $userId = $db->lastInsertId();
    
    // 5. ウェルカムメール送信
    if (!mail($email, 'アプリへようこそ', 'ご登録ありがとうございます!')) {
        error_log("$email へのメール送信に失敗しました");
    }
    
    return ['success' => true, 'message' => '登録に成功しました!', 'userId' => $userId];
}

改善後:より小さく、特化した関数やクラスに分割

// 分割後のクラス(より良い設計)
class UserRegistrationService {
    private $db;
    
    public function __construct(PDO $db) {
        $this->db = $db;
    }
    
    // バリデーション担当
    private function validateRegistrationInputs(string $username, string $email, string $password): array {
        if (empty($username) || empty($email) || empty($password)) {
            return ['isValid' => false, 'message' => 'すべてのフィールドが必須です。'];
        }
        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            return ['isValid' => false, 'message' => 'メール形式が無効です。'];
        }
        return ['isValid' => true];
    }
    
    // 重複チェック担当
    private function checkExistingUser(string $username, string $email): bool {
        $stmt = $this->db->prepare("SELECT COUNT(*) FROM users WHERE username = ? OR email = ?");
        $stmt->execute([$username, $email]);
        return $stmt->fetchColumn() > 0;
    }
    
    // DB保存担当
    private function storeUser(string $username, string $email, string $hashedPassword): ?int {
        $stmt = $this->db->prepare("INSERT INTO users (username, email, password) VALUES (?, ?, ?)");
        if ($stmt->execute([$username, $email, $hashedPassword])) {
            return (int) $this->db->lastInsertId();
        }
        return null;
    }
    
    // メール送信担当
    private function sendWelcomeEmail(string $email, string $username): bool {
        $sent = mail($email, 'アプリへようこそ', "こんにちは $username さん、ご登録ありがとうございます!");
        if (!$sent) {
            error_log("$email へのメール送信に失敗しました");
        }
        return $sent;
    }
    
    // メインプロセス制御
    public function registerUser(string $username, string $email, string $password): array {
        $validation = $this->validateRegistrationInputs($username, $email, $password);
        if (!$validation['isValid']) {
            return ['success' => false, 'message' => $validation['message']];
        }
        
        if ($this->checkExistingUser($username, $email)) {
            return ['success' => false, 'message' => 'ユーザー名またはメールが既に使用されています。'];
        }
        
        $hashedPassword = password_hash($password, PASSWORD_DEFAULT);
        $userId = $this->storeUser($username, $email, $hashedPassword);
        
        if ($userId === null) {
            return ['success' => false, 'message' => '登録に失敗しました。'];
        }
        
        $this->sendWelcomeEmail($email, $username); // メールの成否は登録の成否に影響させない
        
        return ['success' => true, 'message' => '登録に成功しました!', 'userId' => $userId];
    }
}

改善された例では、UserRegistrationService クラスが全体の流れを調整しますが、バリデーション、DBチェック、保存、メール送信などの具体的なタスクは、それぞれ個別のメソッドに委譲されています。これにより、各部分が理解しやすくなり、テスト(例:validateRegistrationInputs だけを個別にテストする)やメンテナンスが容易になります。

6. DRY 原則 (Don't Repeat Yourself / 同じことを繰り返さない)

DRY 原則は、コードの重複を避けることを主張しています。同じロジックやデータが複数の場所に存在すると、それはメンテナンスの負担となります。そのロジックに変更が必要になった際、出現するすべての箇所を修正しなければならず、不一致やバグが発生するリスクが大幅に高まります。

解決策は、重複するコードを再利用可能な関数、クラス、または定数に抽出することです。

<?php
// DRY 原則に違反している例
function displayProductPrice($price) {
    return '¥' . number_format($price, 2);
} 

function generateInvoiceLineItem($productName, $quantity, $unitPrice) {
    $total = $quantity * $unitPrice;
    $formattedTotal = '¥' . number_format($total, 2); // ロジックの重複
    return "$productName x $quantity @ ¥" . number_format($unitPrice, 2) . " = $formattedTotal";
} 

// DRY 原則に従った例
function formatCurrency(float $amount): string {
    return '¥' . number_format($amount, 2);
} 

function generateInvoiceLineItemDRY(string $productName, int $quantity, float $unitPrice): string {
    $total = $quantity * $unitPrice;
    return "$productName x $quantity @ " . formatCurrency($unitPrice) . " = " . formatCurrency($total);
} 

// 使用例
echo "商品価格: " . displayProductPrice(99.99) . "\n";
echo "請求項目 (DRY): " . generateInvoiceLineItemDRY("ノートPC", 2, 1200.50) . "\n";
?>

価格のフォーマットロジックを formatCurrency() に抽出することで、将来的に表示形式(通貨記号の変更や小数点以下の精度の変更など)を変える必要が生じた場合でも、この1箇所のコードを修正するだけで済みます。

7. 総合実戦演習

これまでの原則を組み合わせて、シンプルなブログシステムにおける新しい記事のバリデーション(検証)ロジックを書いてみましょう。

<?php
// バリデーションルールとエラーメッセージの定数を定義
const MIN_TITLE_LENGTH = 5;
const MAX_TITLE_LENGTH = 100;
const MIN_CONTENT_LENGTH = 20; 

const ERROR_TITLE_EMPTY = "記事のタイトルは必須です。";
const ERROR_TITLE_TOO_SHORT = "タイトルは少なくとも " . MIN_TITLE_LENGTH . " 文字必要です。";
const ERROR_TITLE_TOO_LONG = "タイトルは " . MAX_TITLE_LENGTH . " 文字を超えてはいけません。";
const ERROR_CONTENT_EMPTY = "記事の内容は必須です。";
const ERROR_CONTENT_TOO_SHORT = "内容は少なくとも " . MIN_CONTENT_LENGTH . " 文字必要です。"; 

/**
 * 記事の入力データをサニタイズし、バリデーションを行う。
 *
 * @param array $postData 'title' と 'content' を含む連想配列。
 * @return array 'isValid' (bool) と、'errors' (配列) または 'data' (サニタイズ済データ) を含む配列。
 */
function sanitizeAndValidatePost(array $postData): array {
    $errors = [];
    $sanitizedData = [
        'title' => '',
        'content' => ''
    ];
    
    // --- タイトルのバリデーション ---
    if (!isset($postData['title']) || empty(trim($postData['title']))) {
        $errors[] = ERROR_TITLE_EMPTY;
    } else {
        $sanitizedData['title'] = htmlspecialchars(trim($postData['title']), ENT_QUOTES, 'UTF-8');
        if (mb_strlen($sanitizedData['title']) < MIN_TITLE_LENGTH) {
            $errors[] = ERROR_TITLE_TOO_SHORT;
        }
        if (mb_strlen($sanitizedData['title']) > MAX_TITLE_LENGTH) {
            $errors[] = ERROR_TITLE_TOO_LONG;
        }
    }
    
    // --- 内容のバリデーション ---
    if (!isset($postData['content']) || empty(trim($postData['content']))) {
        $errors[] = ERROR_CONTENT_EMPTY;
    } else {
        $sanitizedData['content'] = htmlspecialchars(trim($postData['content']), ENT_QUOTES, 'UTF-8');
        if (mb_strlen($sanitizedData['content']) < MIN_CONTENT_LENGTH) {
            $errors[] = ERROR_CONTENT_TOO_SHORT;
        }
    }
    
    if (empty($errors)) {
        return ['isValid' => true, 'data' => $sanitizedData];
    } else {
        return ['isValid' => false, 'errors' => $errors];
    }
} 

// 例 1: 有効な記事データ
echo "--- 例 1: 有効な記事 ---\n";
$validPost = [
    'title' => '  はじめてのブログ記事  ',
    'content' => 'これは私の最初のブログ記事の内容です。バリデーションを通過するために少なくとも20文字必要です。'
];
$result1 = sanitizeAndValidatePost($validPost);
if ($result1['isValid']) {
    echo "バリデーション成功!\n";
    print_r($result1['data']);
} 

// 例 2: 無効な記事データ (フィールドが空)
echo "\n--- 例 2: 無効な記事 (空フィールド) ---\n";
$invalidPostEmpty = [
    'title' => '',
    'content' => '短すぎる'
];
$result2 = sanitizeAndValidatePost($invalidPostEmpty);
if (!$result2['isValid']) {
    echo "バリデーション失敗:\n";
    foreach ($result2['errors'] as $error) { echo "- " . $error . "\n"; }
}
?>

この例のポイント:

  • 明確な命名: 関数名や定数名がその用途をはっきりと示しています。
  • マジックバリューの排除: 長さの制限やエラーメッセージに定数を使用しています。
  • ドキュメント規格: 関数には /** ... */ ドックブロック(Docblock)が付与され、機能、引数、戻り値を説明しています。
  • 明確なロジック: タイトルと内容の検証ロジックがグループ化され、フローを追いやすくなっています。
  • 型宣言: 引数に型ヒント(array $postData)を使用し、正しい型のデータを受け取ることを保証しています。