要点まとめ
- 原因:Cookieの中身が“純粋なJSONではない”、あるいは空/
null
/undefined
が残っているのにサーバ側で弾いていない。 - 対策:JSON→ダメならプレーン文字列として受け、型ごとにバリデーションし、不正時は確実に既定値へフォールバック。
- 付録:汎用PHPヘルパ(全文)/ JSの書き込みヘルパ / デバッグ用プローブ / 使い方サンプルを収録。
概要
「ローカルでは動くのに、本番だけ 10
のデフォルトが入らない」。Cookie を介した表示件数やソート条件の保持でよく起こる問題です。原因の多くは Cookie の中身が純粋な JSON ではない、または 空文字・null
・undefined
が残っているのに、サーバ側でそれを適切に弾いていないこと。この記事では、JSON/プレーンの両対応・バリデーション・フェイルセーフを満たす実装パターンを紹介します。
よくある落とし穴
isset($_COOKIE[$key])
が true でもjson_decode()
がnull
を返すケース(中身が JSON ではない、または空文字)。- クライアント(JS)の実装差により、環境によって
10
をプレーン文字列のまま保存している。 - デプロイ前のテストで作った古い Cookie が プロダクションで生き残っており、空/無効値が混入。
$_COOKIE
の値を 配列/オブジェクトで期待していないのに JSON で保存され、json_decode()
で 配列が返ってきて壊れる。
解決アプローチ
- サーバ側で頑強に受ける
- JSON を試す → ダメならプレーン文字列として扱う
- 空/
null
/undefined
/配列/オブジェクト/範囲外は 既定値にフォールバック
- (任意)クライアントは JSON で統一保存
- 将来、複合値に発展しても壊れにくい
- デバッグポイントを仕込む
- 一時的に
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の影響を疑い、クリア手順をドキュメント化すると運用が安定します。
コメント