Cookieの値が本番だけおかしい?PHPとJSで実装する「安全なCookie読み出しとデフォルト値フォールバック」完全ガイド

要点まとめ

  • 原因:Cookieの中身が“純粋なJSONではない”、あるいは空/null/undefinedが残っているのにサーバ側で弾いていない。
  • 対策JSON→ダメならプレーン文字列として受け、型ごとにバリデーションし、不正時は確実に既定値へフォールバック
  • 付録:汎用PHPヘルパ(全文)/ JSの書き込みヘルパ / デバッグ用プローブ / 使い方サンプルを収録。

概要

「ローカルでは動くのに、本番だけ 10 のデフォルトが入らない」。Cookie を介した表示件数やソート条件の保持でよく起こる問題です。原因の多くは Cookie の中身が純粋な JSON ではない、または 空文字・nullundefined が残っているのに、サーバ側でそれを適切に弾いていないこと。この記事では、JSON/プレーンの両対応バリデーションフェイルセーフを満たす実装パターンを紹介します。


よくある落とし穴

  • isset($_COOKIE[$key]) が true でも json_decode()null を返すケース(中身が JSON ではない、または空文字)。
  • クライアント(JS)の実装差により、環境によって 10 をプレーン文字列のまま保存している。
  • デプロイ前のテストで作った古い Cookie が プロダクションで生き残っており、空/無効値が混入。
  • $_COOKIE の値を 配列/オブジェクトで期待していないのに JSON で保存され、json_decode()配列が返ってきて壊れる

解決アプローチ

  1. サーバ側で頑強に受ける
  • JSON を試す → ダメならプレーン文字列として扱う
  • 空/null/undefined/配列/オブジェクト/範囲外は 既定値にフォールバック
  1. (任意)クライアントは JSON で統一保存
  • 将来、複合値に発展しても壊れにくい
  1. デバッグポイントを仕込む
  • 一時的に error_log() で Cookie 生値と json_last_error_msg() を確認

汎用コード(PHPヘルパ:全文)

includes/helpers/cookie_safe.php として保存(ダミーキー前提・再利用可)

<?php
/**
 * Cookie から安全に値を取り出すユーティリティ群(ダミーキー前提・再利用可)
 *
 * 特徴:
 * - JSON/プレーン文字列の両方を許容("10" も 10 も OK)
 * - よくある不正値(空文字, "null", "undefined")は既定値へフォールバック
 * - 型別にバリデーション(int: 範囲 / string: 長さと正規表現 / bool: true/false表現)
 * - 名前空間は使わない(既存方針に合わせる)
 *
 * 使い方(例):
 *   require_once __DIR__ . '/cookie_safe.php';
 *   $perPage = cookie_get_int_safe('MYAPP_demo_per_page', 10, 1, 100);
 *   $sort    = cookie_get_string_safe('MYAPP_demo_sort', 'date', 0, 32, '/^(date|name|price)$/');
 *   $flag    = cookie_get_bool_safe('MYAPP_demo_flag', false);
 */

if (!function_exists('cookie_raw_or_null')) {
    /**
     * Cookie 生値を取得(存在しない/空や無効語は null に正規化)
     *
     * @param string $key Cookieキー
     * @return ?string 正常な文字列 or null
     */
    function cookie_raw_or_null(string $key): ?string
    {
        if (!isset($_COOKIE[$key])) return null;

        $raw = (string)$_COOKIE[$key];
        $raw = trim($raw);

        $lower = strtolower($raw);
        if ($raw === '' || $lower === 'null' || $lower === 'undefined') {
            return null;
        }
        return $raw;
    }
}

if (!function_exists('cookie_decode_flexible')) {
    /**
     * JSON を試し、ダメならプレーン文字列として返す
     *
     * @param ?string $raw cookie_raw_or_null() の結果
     * @return mixed JSONで解釈できればその値(配列/オブジェクト含む可能性あり)/ できなければプレーン文字列 / null
     */
    function cookie_decode_flexible(?string $raw)
    {
        if ($raw === null) return null;

        $decoded = json_decode($raw, true);
        if (json_last_error() === JSON_ERROR_NONE) {
            return $decoded;
        }
        return $raw;
    }
}

if (!function_exists('cookie_get_int_safe')) {
    /**
     * int を安全に取得(範囲チェック込み)
     *
     * @param string $key     Cookieキー
     * @param int    $default デフォルト値
     * @param int    $min     最小値
     * @param int    $max     最大値
     * @return int
     */
    function cookie_get_int_safe(string $key, int $default = 0, int $min = PHP_INT_MIN, int $max = PHP_INT_MAX): int
    {
        $raw = cookie_raw_or_null($key);
        if ($raw === null) return $default;

        $candidate = cookie_decode_flexible($raw);

        // 配列/オブジェクトは不正
        if (is_array($candidate) || is_object($candidate)) return $default;

        $val = filter_var($candidate, FILTER_VALIDATE_INT, [
            'options' => ['min_range' => $min, 'max_range' => $max],
        ]);

        return ($val !== false) ? (int)$val : $default;
    }
}

if (!function_exists('cookie_get_string_safe')) {
    /**
     * string を安全に取得(長さ・正規表現チェック)
     *
     * @param string      $key
     * @param string      $default
     * @param int         $minLen
     * @param int         $maxLen
     * @param string|null $pattern 例: '/^(asc|desc)$/'
     * @return string
     */
    function cookie_get_string_safe(string $key, string $default = '', int $minLen = 0, int $maxLen = 255, ?string $pattern = null): string
    {
        $raw = cookie_raw_or_null($key);
        if ($raw === null) return $default;

        $candidate = cookie_decode_flexible($raw);

        // 配列/オブジェクトは不正
        if (is_array($candidate) || is_object($candidate)) return $default;

        $s = trim((string)$candidate);
        $len = mb_strlen($s, 'UTF-8');

        if ($len < $minLen || $len > $maxLen) return $default;
        if ($pattern !== null && !preg_match($pattern, $s)) return $default;

        return $s;
    }
}

if (!function_exists('cookie_get_bool_safe')) {
    /**
     * bool を安全に取得
     *
     * 許容する真偽の書式例:
     * - 真:  true / "true" / 1 / "1" / "yes" / "on"
     * - 偽:  false / "false" / 0 / "0" / "no" / "off"
     *
     * @param string $key
     * @param bool   $default
     * @return bool
     */
    function cookie_get_bool_safe(string $key, bool $default = false): bool
    {
        $raw = cookie_raw_or_null($key);
        if ($raw === null) return $default;

        $candidate = cookie_decode_flexible($raw);

        if (is_bool($candidate)) return $candidate;

        $s = strtolower(trim((string)$candidate));
        if ($s === 'true' || $s === '1' || $s === 'yes' || $s === 'on')  return true;
        if ($s === 'false' || $s === '0' || $s === 'no' || $s === 'off') return false;

        if (is_numeric($candidate)) {
            return ((int)$candidate) !== 0;
        }
        return $default;
    }
}

使い方(PHPサンプル:全文)

どのテンプレートでも再利用できるダミー例。

<?php
/**
 * File: pages/demo_list.php(ダミー)
 * 目的: Cookieから表示件数・並び順・フラグを安全取得して、JSにも受け渡す例
 */
require_once __DIR__ . '/../includes/helpers/cookie_safe.php';

// ダミーのプリフィクス & 画面プレフィクス
const COOKIE_PREFIX = 'MYAPP_';
$pagePrefix = 'demo_';

// ダミーCookieキー
$perPageKey = COOKIE_PREFIX . $pagePrefix . 'per_page';
$sortKey    = COOKIE_PREFIX . $pagePrefix . 'sort';
$orderKey   = COOKIE_PREFIX . $pagePrefix . 'sort_order';
$featureKey = COOKIE_PREFIX . $pagePrefix . 'feature_flag';

// 安全に取得(デフォルト・バリデーション含む)
$selectedPerPage = cookie_get_int_safe($perPageKey, 10, 1, 100);
$selectedSort    = cookie_get_string_safe($sortKey, 'date', 0, 32, '/^(date|name|price)$/');
$selectedOrder   = cookie_get_string_safe($orderKey, 'desc', 3, 4, '/^(asc|desc)$/');
$featureEnabled  = cookie_get_bool_safe($featureKey, false);

// 以降は通常のレンダリングやAPI呼び出しに利用
?>
<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <title>ダミー一覧</title>
</head>
<body>
  <h1>ダミー一覧</h1>

  <!-- JSに安全に受け渡し(XSS対策で json_encode を使用) -->
  <script>
  const APP = (function() {
    return {
      perPage:  <?= json_encode($selectedPerPage, JSON_UNESCAPED_UNICODE) ?>,
      sort:     <?= json_encode($selectedSort,    JSON_UNESCAPED_UNICODE) ?>,
      order:    <?= json_encode($selectedOrder,   JSON_UNESCAPED_UNICODE) ?>,
      feature:  <?= json_encode($featureEnabled,  JSON_UNESCAPED_UNICODE) ?>,
      keys: {
        perPage: <?= json_encode($perPageKey) ?>,
        sort:    <?= json_encode($sortKey) ?>,
        order:   <?= json_encode($orderKey) ?>,
        feature: <?= json_encode($featureKey) ?>
      }
    };
  })();
  console.log('APP config from PHP:', APP);
  </script>

  <p>PHPで取得した per_page: <?= htmlspecialchars((string)$selectedPerPage, ENT_QUOTES, 'UTF-8') ?></p>
  <p>PHPで取得した sort:     <?= htmlspecialchars($selectedSort, ENT_QUOTES, 'UTF-8') ?></p>
  <p>PHPで取得した order:    <?= htmlspecialchars($selectedOrder, ENT_QUOTES, 'UTF-8') ?></p>
  <p>PHPで取得した feature:  <?= $featureEnabled ? 'ON' : 'OFF' ?></p>
</body>
</html>

JSユーティリティ(書き込み側:全文)

assets/js/cookie_utils.js などに保存。JSONで統一保存が推奨。

<script>
/**
 * Cookie を JSON で保存(値は JSON.stringify)
 * SameSite=Lax, Secure=true を既定(HTTPS前提)
 */
function setCookieJSON(key, value, days = 365, path = '/', domain = '', secure = true, sameSite = 'Lax') {
  const enc = encodeURIComponent;
  const expires = days ? `; expires=${new Date(Date.now() + days * 864e5).toUTCString()}` : '';
  const p  = path   ? `; path=${path}`   : '';
  const d  = domain ? `; domain=${domain}` : '';
  const s  = secure ? '; secure' : '';
  const ss = sameSite ? `; samesite=${sameSite}` : '';
  document.cookie = `${enc(key)}=${enc(JSON.stringify(value))}${expires}${p}${d}${s}${ss}`;
}

/** プレーン文字列で保存したい場合 */
function setCookiePlain(key, value, days = 365, path = '/', domain = '', secure = true, sameSite = 'Lax') {
  const enc = encodeURIComponent;
  const expires = days ? `; expires=${new Date(Date.now() + days * 864e5).toUTCString()}` : '';
  const p  = path   ? `; path=${path}`   : '';
  const d  = domain ? `; domain=${domain}` : '';
  const s  = secure ? '; secure' : '';
  const ss = sameSite ? `; samesite=${sameSite}` : '';
  document.cookie = `${enc(key)}=${enc(String(value))}${expires}${p}${d}${s}${ss}`;
}

/* 利用例(ダミーキー) */
const COOKIE_PREFIX = 'MYAPP_';
const pagePrefix   = 'demo_';

const KEY_PER_PAGE = COOKIE_PREFIX + pagePrefix + 'per_page';
const KEY_SORT     = COOKIE_PREFIX + pagePrefix + 'sort';
const KEY_ORDER    = COOKIE_PREFIX + pagePrefix + 'sort_order';
const KEY_FEATURE  = COOKIE_PREFIX + pagePrefix + 'feature_flag';

setCookieJSON(KEY_PER_PAGE, 20);
setCookieJSON(KEY_SORT, 'name');
setCookieJSON(KEY_ORDER, 'asc');
setCookieJSON(KEY_FEATURE, true);
</script>

デバッグ用プローブ(任意:全文)

ログで「生値」と JSON エラーを確認し、原因切り分けに活用。

<?php
// File: includes/debug/cookie_probe.php(任意)

/**
 * 指定キーの Cookie 生値と json_decode の状態をログ出力する簡易プローブ。
 * 本番では短期間・アクセス制限下でのみ使用推奨。
 */
function debug_log_cookie_probe(string $key): void
{
    $raw = $_COOKIE[$key] ?? null;
    error_log("[cookie_probe] key={$key} raw=" . var_export($raw, true));

    if (isset($_COOKIE[$key])) {
        json_decode((string)$_COOKIE[$key], true);
        error_log("[cookie_probe] key={$key} json_error=" . json_last_error() . ' / ' . json_last_error_msg());
    }
}

// 使い方(ダミー):
// debug_log_cookie_probe('MYAPP_demo_per_page');

チェックリスト

  • Cookie のキーは環境差を吸収できているか(プリフィクスや画面識別の設計)
  • JSON/プレーン文字列の 両対応になっているか
  • 数値は 範囲チェックmin_range / max_range)をしているか
  • 文字列は 長さパターン(正規表現)をチェックしているか
  • 空/null/undefined既定値へ確実にフォールバックしているか
  • 本番リリース前に 古い Cookie をクリアして検証したか

本番だけ発生しがちな理由

  • ドメイン/サブドメイン/パスの違いで 別スコープの Cookie が残留
  • HTTPS 化や SameSite / Secure 属性差で 読み書きの挙動がズレる
  • ローカルと本番で JS バンドルのバージョンが違い、保存形式が変化
  • CloudFront/リバプロ経由で HTTP ヘッダ差(稀に Cookie の扱いに影響)

まとめ

  • サーバ側で何が来ても安全に既定値へ落とす実装が最強の保険。
  • クライアント側は JSONで統一保存が拡張に強い。
  • リリースごとに 古いCookieの影響を疑い、クリア手順をドキュメント化すると運用が安定します。

コメント

タイトルとURLをコピーしました