モダンPHP(8.4 / 8.5 系)を前提とした、実務で使えるベストプラクティス集です。
目次
- バージョンと実行環境
- コーディング規約
- 型を徹底する
- モダンな言語機能を使う
- 設計の原則
- エラー処理
- セキュリティ
- 依存関係の管理(Composer)
- テストと静的解析
- パフォーマンス
- 本番環境の設定
- 避けるべきアンチパターン
- チェックリスト
1. バージョンと実行環境
- サポート中のバージョンを使う。 セキュリティサポートが切れたバージョン(8.1 以前)は使わない。新規プロジェクトは最新の安定版(8.5 系)を選ぶ。
- 対応バージョンは
composer.jsonで明示する。
{
"require": {
"php": "^8.4"
}
}- 開発・CI・本番で 同じマイナーバージョン を使う(Docker などで揃える)。
- パッチリリース(例: 8.5.x)はセキュリティ修正を含むため、こまめに適用する。
2. コーディング規約
- PSR-12 / PER Coding Style に準拠する。手作業で揃えず、ツールで自動整形する。
- オートロードは PSR-4 に従う。名前空間とディレクトリ構造を一致させる。
{
"autoload": {
"psr-4": { "App\\": "src/" }
},
"autoload-dev": {
"psr-4": { "Tests\\": "tests/" }
}
}- 1ファイル1クラス。PHPのみのファイルでは閉じタグ
?>を書かない(末尾の空白出力事故を防ぐ)。 - 命名規則
対象 規則 例 クラス・インターフェース・Enum PascalCase UserRepositoryメソッド・変数 camelCase findById(),$userId定数・Enum case UPPER_SNAKE_CASE / PascalCase MAX_RETRY,Status::Active
3. 型を徹底する
strict_types を必ず宣言する
<?php
declare(strict_types=1);暗黙の型変換("5" → 5 など)を防ぎ、バグを早期に発見できる。
引数・戻り値・プロパティすべてに型を付ける
// ❌ 悪い例
function calc($price, $qty) {
return $price * $qty;
}
// ✅ 良い例
function calc(int $price, int $qty): int
{
return $price * $qty;
}- Union型
int|string、Nullable型?User、never、void、staticなどを適切に使う。 mixedは最後の手段。配列の中身は PHPDoc(@param list<User> $users、@return array<string, int>)で静的解析ツールに伝える。
配列より値オブジェクト・DTOを使う
// ❌ 連想配列はキーのタイポや型の不一致に気付けない
$user = ['name' => 'Taro', 'email' => 'taro@example.com'];
// ✅ readonly クラスで構造を保証する
final readonly class UserData
{
public function __construct(
public string $name,
public string $email,
) {}
}4. モダンな言語機能を使う
コンストラクタプロモーション + readonly
final class OrderService
{
public function __construct(
private readonly OrderRepository $orders,
private readonly Mailer $mailer,
) {}
}Enum(文字列定数・マジックナンバーの代替)
enum OrderStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Cancelled = 'cancelled';
public function label(): string
{
return match ($this) {
self::Pending => '保留中',
self::Paid => '支払済',
self::Cancelled => 'キャンセル',
};
}
}
$status = OrderStatus::from($row['status']); // 不正値は例外
$status = OrderStatus::tryFrom($input) ?? OrderStatus::Pending;match 式(switch の代替)
match は厳密比較(===)で、値を返し、該当なしの場合は UnhandledMatchError を投げるため安全。
$fee = match (true) {
$amount >= 10000 => 0,
$amount >= 5000 => 300,
default => 500,
};その他の便利な構文
// nullsafe 演算子
$city = $user?->getAddress()?->city;
// 名前付き引数(引数の意味が明確になる)
$str = str_pad(string: $id, length: 8, pad_string: '0', pad_type: STR_PAD_LEFT);
// ファーストクラスcallable
$upper = array_map(strtoupper(...), $names);
// 8.4: new の括弧省略
$name = new User('Taro')->name;
// 8.4: 配列検索関数
$admin = array_find($users, fn(User $u) => $u->isAdmin());プロパティフック・非対称可視性(8.4〜)
getter/setter の定型コードを減らせる。
final class Product
{
// 外部からは読み取りのみ、内部からは書き込み可
public private(set) int $stock = 0;
public string $name {
set(string $value) {
if ($value === '') {
throw new InvalidArgumentException('商品名は必須です');
}
$this->name = $value;
}
}
}パイプ演算子・clone with(8.5〜)
// 8.5: パイプ演算子で処理を左から右へ読めるようにする
$slug = $title
|> trim(...)
|> strtolower(...)
|> (fn(string $s) => preg_replace('/[^a-z0-9]+/', '-', $s));
// 8.5: readonly オブジェクトの一部を変えたコピーを作る
$updated = clone($user, ['email' => 'new@example.com']);使用する機能はプロジェクトの最低対応バージョンに合わせること。
5. 設計の原則
- 依存性注入(DI)を使う。 クラス内で
newやグローバル関数・静的呼び出しで依存を生成しない。テスト時に差し替えられなくなる。
// ❌ 依存がハードコードされている
class ReportService
{
public function send(): void
{
$mailer = new SmtpMailer(); // 差し替え不能
}
}
// ✅ インターフェースに依存し、外から注入する
final class ReportService
{
public function __construct(private readonly MailerInterface $mailer) {}
}- デフォルトで
finalにする。 継承前提でないクラスはfinal。拡張はインターフェースとコンポジションで行う。 - イミュータブルを優先する。
readonlyプロパティ /readonly classを使い、変更時は新しいインスタンスを返す。 - 単一責任。 1クラス・1メソッドの責務を小さく保つ。巨大な「Util」「Helper」「Manager」クラスを作らない。
- 早期リターン でネストを浅くする。
public function process(?Order $order): void
{
if ($order === null) {
return;
}
if (!$order->isPayable()) {
throw new DomainException('支払い不可の注文です');
}
// 本処理
}- グローバル変数・
staticな可変状態を避ける。 - 日時は
DateTimeImmutableを使い、DateTime(ミュータブル)は避ける。現在時刻はクロックを注入してテスト可能にする(PSR-20ClockInterface)。
6. エラー処理
- 例外を使う。 エラー時に
falseやnullを返して呼び出し側に判定させる設計は避ける。 - ドメイン固有の例外クラス を作り、意味のある単位で捕捉する。
final class InsufficientStockException extends DomainException
{
public static function forProduct(int $productId): self
{
return new self("在庫不足: product_id={$productId}");
}
}- 例外を握りつぶさない。
catch (Exception $e) {}は禁止。再スローする場合はpreviousを渡して原因を残す。
try {
$this->gateway->charge($amount);
} catch (GatewayException $e) {
throw new PaymentFailedException('決済に失敗しました', previous: $e);
}- エラー抑制演算子
@を使わない。 - 開発環境では
error_reporting(E_ALL)とし、警告・Deprecated もすべて潰す。 - ログは PSR-3(Monolog など)で構造化して出力し、パスワード・トークン・個人情報をログに含めない。
- 戻り値を無視してはいけない関数には
#[\NoDiscard](8.5〜)を付ける。
7. セキュリティ
SQLインジェクション対策:プリペアドステートメント
// ❌ 絶対に文字列連結しない
$pdo->query("SELECT * FROM users WHERE email = '$email'");
// ✅ プレースホルダを使う
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = :email');
$stmt->execute(['email' => $email]);PDO の推奨設定:
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);※ テーブル名・カラム名・ORDER BY はプレースホルダにできないため、ホワイトリスト で検証する。
XSS対策:出力時にエスケープ
function h(string $s): string
{
return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}- テンプレートエンジン(Twig、Blade)の自動エスケープを有効にする。
- Content-Security-Policy ヘッダーを設定する。
パスワード
$hash = password_hash($password, PASSWORD_DEFAULT); // または PASSWORD_ARGON2ID
if (password_verify($input, $hash)) {
if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
// 新しいアルゴリズムで再ハッシュして保存
}
}md5() / sha1() / 独自ハッシュは使わない。
その他
| 項目 | 対策 |
|---|---|
| 乱数・トークン | random_bytes() / random_int() を使う(rand() / mt_rand() / uniqid() は不可) |
| CSRF | フォームにトークンを埋め込み hash_equals() で比較。Cookie に SameSite を設定 |
| セッション | ログイン時に session_regenerate_id(true)。Cookie は Secure / HttpOnly / SameSite=Lax 以上 |
| 入力検証 | すべての外部入力($_GET、$_POST、ヘッダー、Cookie、ファイル)を信用しない。filter_var() やバリデーションライブラリで検証 |
| デシリアライズ | 信頼できないデータに unserialize() を使わない。json_decode() を使う |
| ファイルアップロード | MIMEタイプをサーバー側で検証(finfo)、ファイル名は再生成、公開ディレクトリ外に保存 |
| コマンド実行 | exec() 等は極力避ける。必要なら escapeshellarg() で引数をエスケープ |
| 機密情報 | APIキー・DBパスワードはコードに書かず環境変数やシークレット管理で扱う。.env は Git にコミットしない |
| 依存パッケージ | composer audit で既知の脆弱性を定期チェック |
8. 依存関係の管理(Composer)
composer.lockは必ずコミットする(アプリケーションの場合)。ライブラリの場合はコミットしない運用が一般的。- 本番デプロイは以下で行う:
composer install --no-dev --optimize-autoloader --classmap-authoritative- バージョン制約はキャレット
^を基本にし、*やdev-masterを本番依存に使わない。 - 定期的に
composer outdatedとcomposer auditを実行する(Dependabot / Renovate で自動化推奨)。 - 自前実装の前に、PSR準拠の定評あるパッケージがないか確認する。
9. テストと静的解析
テスト
- PHPUnit または Pest で自動テストを書く。
- ビジネスロジックはユニットテスト、DBや外部APIとの結合部分は統合テストで検証する。
- テストは CI で毎回実行する。
final class PriceCalculatorTest extends TestCase
{
#[Test]
public function 送料無料の閾値以上では送料が0円になる(): void
{
$calc = new PriceCalculator();
self::assertSame(0, $calc->shippingFee(10000));
}
}静的解析・品質ツール
| ツール | 用途 |
|---|---|
| PHPStan / Psalm | 型エラー・バグの静的検出。レベルは段階的に上げ、新規は最大レベルを目指す |
| PHP-CS-Fixer / PHP_CodeSniffer | コーディング規約の自動整形・チェック |
| Rector | 新しいPHPバージョンへの自動リファクタリング・移行 |
| Infection | ミューテーションテストでテストの質を測る |
既存プロジェクトでは PHPStan の baseline 機能を使い、新規コードから厳格化していく。
10. パフォーマンス
- OPcache を必ず有効化 する(本番では
opcache.validate_timestamps=0にしてデプロイ時にリセット)。 - プリロード(
opcache.preload)や JIT はベンチマークで効果を確認してから導入する。Webアプリでは I/O がボトルネックのことが多い。 - N+1 クエリを避ける。 ループ内でクエリを発行しない。ORM ではEagerロードを使う。
- 大量データは ジェネレーター(
yield) で逐次処理し、メモリ使用量を抑える。
function readLines(string $path): Generator
{
$handle = fopen($path, 'rb');
try {
while (($line = fgets($handle)) !== false) {
yield $line;
}
} finally {
fclose($handle);
}
}- 重い処理はキュー(非同期ジョブ)に逃がす。
- 推測で最適化せず、プロファイラ(Xdebug、Blackfire、SPX など)で計測してから手を入れる。
- 頻繁に読まれるデータは APCu / Redis などでキャッシュする。
11. 本番環境の設定
php.ini の主な推奨設定:
display_errors = Off
display_startup_errors = Off
log_errors = On
error_reporting = E_ALL
expose_php = Off
session.cookie_secure = 1
session.cookie_httponly = 1
session.cookie_samesite = Lax
session.use_strict_mode = 1
opcache.enable = 1
opcache.validate_timestamps = 0- エラー詳細は画面に出さず、ログにのみ出力する。
- ドキュメントルートは
public/のみにし、src/・vendor/・.envを Web から見えない場所に置く。 - 不要な拡張モジュールは無効化する。
12. 避けるべきアンチパターン
| アンチパターン | 代替 |
|---|---|
== による緩い比較 | === を使う |
extract() / 可変変数 $$name | 明示的な変数・配列アクセス |
eval() | 使わない |
global キーワード | 依存性注入 |
@ によるエラー抑制 | 例外処理・事前チェック |
mysql_* 関数 / SQL文字列連結 | PDO + プリペアドステートメント |
| HTMLとロジックの混在 | テンプレートエンジン・MVC |
| 巨大な連想配列の受け渡し | DTO / 値オブジェクト |
| マジックナンバー・マジック文字列 | 定数・Enum |
empty() の多用 | 意図が明確な比較(=== ''、=== []、=== null) |
何でもできる __get / __call | 明示的なメソッド・プロパティ |
| 例外の握りつぶし | 適切に処理するかログを残して再スロー |
13. チェックリスト
コードレビュー・新規プロジェクト立ち上げ時の確認用。
- サポート中の PHP バージョンを使っている
- 全ファイルに
declare(strict_types=1);がある - 引数・戻り値・プロパティに型がある
- PSR-4 オートロード、PSR-12 / PER-CS 準拠(自動整形が CI に組み込まれている)
- PHPStan / Psalm を CI で実行している
- 自動テストがあり CI で実行している
- SQL はすべてプリペアドステートメント
- 出力はすべてエスケープされている
- パスワードは
password_hash()で保存している - 機密情報がリポジトリに含まれていない
-
composer.lockをコミットし、composer auditを定期実行している - 本番で
display_errors = Off、OPcache が有効 - ドキュメントルートが
public/のみ