前言
setcookie()的作用是让 PHP 在响应里追加一个Set-Cookie响应头,从而把一小段数据交给浏览器保存下来,并在后续请求里自动带回来。听起来很简单,但它牵扯到 HTTP 协议本身的一条硬性限制:Cookie 属于响应头的一部分,必须在任何响应体输出之前发送。
围绕这个函数最常见的三个误解是:一,以为调用setcookie()后立刻就能从$_COOKIE里读出来(实际上是下一次请求才有);二,以为设了httponly就万无一失(它只能挡住脚本读取,挡不住篡改);三,以为 Cookie 有保密性(它明文存在用户机器上,用户想怎么改就怎么改)。
本文按 PHP 官方手册讲清两种调用签名、各参数(尤其是samesite)的含义、删除 Cookie 的正确姿势,以及把 Cookie 用在不该用的地方会带来什么后果。
一、两种签名与参数含义
第一种是传统的位置参数写法:
setcookie(string $name, string $value = "", int $expires_or_options = 0,
string $path = "", string $domain = "", bool $secure = false,
bool $httponly = false): bool
第二种自 PHP 7.3.0 起提供,把后面几个参数合并成关联数组:
setcookie(string $name, string $value = "", array $options = []): bool
手册特别注明:数组形式的签名不支持命名参数。$options允许的键有expires、path、domain、secure、httponly、samesite、partitioned(partitioned是更晚的版本才加入的)。没给出的键会沿用与位置参数相同的默认值;samesite如果省略,PHP 不会发送任何 SameSite 属性。
name | string | Cookie 名。取回时用$_COOKIE['name'] |
value | string | Cookie 值。会明文存在用户机器上,不要放敏感信息 |
expires_or_options | int | Unix 时间戳(不是日期字符串)。传0或省略表示「会话 Cookie」,浏览器关闭即失效 |
path | string | 生效路径。设为/表示整站可见;设为/admin/表示只在/admin/及其子目录可见 |
domain | string | 生效域名。设为example.com则其所有子域都可见 |
httponly | bool | 不允许 JavaScript 读取(document.cookie拿不到),能降低 XSS 场景下的会话窃取风险 |
samesite | string | 取值只应是None、Lax或Strict;None必须同时开启secure,否则会被浏览器丢弃 |
expires_or_options用时间戳而不是Wdy, DD-Mon-YYYY HH:MM:SS GMT这种日期格式,是因为时区转换由 PHP 内部完成。常见写法是time() + 60 * 60 * 24 * 30(30 天后过期)。
二、必须在任何输出之前调用
setcookie()干的事本质上是加一个响应头。按 HTTP 协议,响应头必须先于响应体发出;一旦 PHP 已经向浏览器输出了任何字节,头部就锁死了。手册的措辞是:如果有输出先于本函数存在,setcookie()会失败并返回false。
这里的「输出」比想象中宽得多,包括:
- 任何
echo、print、printf;
?>关闭标签之后的空行或空格(这就是纯 PHP 文件不要写结尾?>的原因);
- 被
include进来的文件产生的输出;
- 文件开头被编辑器加上的 UTF-8 BOM(
\xEF\xBB\xBF);
- PHP 启动阶段的错误或警告信息。
配置里开了output_buffering时,输出会先进入缓冲区而不是直接发出,看起来「晚点调用也没事」,但这只是把问题掩盖了:缓冲区一旦刷出,同样的错误会出现在另一个位置,更难排查。定位这类问题最有效的手段是headers_sent():
<?php // 适用于 PHP 8.0+
if (headers_sent($file, $line)) {
// $file 与 $line 指向「第一次输出」发生的位置
error_log("响应头已发送,位置: {$file}:{$line}");
} else {
setcookie('theme', 'dark', [
'expires' => time() + 86400,
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]);
}
三、读取、删除与安全属性
读取只能在下一次请求里发生。$_COOKIE是 PHP 在处理请求时根据请求头里的Cookie字段一次性填充的,当前请求中新设置的 Cookie 不会自动出现在里面。本次请求内要继续用这个值,就在变量里留一份。
删除 Cookie 的做法是把过期时间设到过去,并且path与domain必须和当初设置时一致,否则浏览器会认为这是另一个 Cookie,旧的依然保留:
<?php // 适用于 PHP 8.0+
setcookie('theme', '', [
'expires' => time() - 3600,
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]);
关于安全属性,要准确理解它们能防什么:httponly让 JavaScript 读不到这个 Cookie,能减少 XSS 造成的会话窃取面,但对手可以直接在浏览器里改 Cookie;secure保证只在加密连接上传输,防的是明文链路上的嗅探;samesite控制跨站请求是否带上 Cookie,是缓解 CSRF 的基础手段之一。
绝对不要把有业务含义的数据直接放在 Cookie 里。「用户 ID」「角色是 admin」「商品价格」这类字段,用户改一下 Cookie 就改了业务逻辑。正确做法是只放一个不可猜的会话标识,真实状态放在服务端;如果确实需要在客户端保存状态,就要加签名:
<?php // 适用于 PHP 8.0+
declare(strict_types=1);
function signValue(string $value, string $key): string
{
$payload = base64_encode($value); // 编码,不是加密
$mac = hash_hmac('sha256', $payload, $key);
return $payload . '.' . $mac;
}
function verifyValue(string $cookie, string $key): string|false
{
$parts = explode('.', $cookie, 2);
if (count($parts) !== 2) {
return false;
}
[$payload, $mac] = $parts;
$calc = hash_hmac('sha256', $payload, $key);
if (!hash_equals($mac, $calc)) {
return false;
}
$decoded = base64_decode($payload, true);
return $decoded === false ? false : $decoded;
}
$key = 'server-side-secret-key'; // 真实项目里来自配置,不要硬编码在仓库中
$value = signValue('theme=dark', $key);
setcookie('pref', $value, [
'expires' => time() + 86400,
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]);
注意这里用base64_encode()只是为了把任意字节塞进 Cookie 值里不产生非法字符,它提供的是编码,不是保密性;真正防篡改的是hash_hmac()加hash_equals()的常量时间比较。
常见坑点
- ❌ 设完 Cookie 立刻读
$_COOKIE['theme']—— 当前请求里它还不存在,只有浏览器的下一次请求才会把它带上来。✅ 本次请求内用变量保存要用的值;$_COOKIE只用来读「上一次带来的」数据。
- ❌ 在
setcookie()前面有echo、有?>之后的空行、或文件带 UTF-8 BOM,导致Warning: Cannot modify header information - headers already sent。✅ 纯 PHP 文件不写结尾?>,编辑器保存为「无 BOM 的 UTF-8」,并把业务逻辑放在输出之前;用headers_sent($file, $line)打印出「第一次输出」的位置来定位。
- ❌ 删除 Cookie 时只写
setcookie('theme', '')—— 没有过期时间,浏览器把它当成一个「值变为空」的会话 Cookie 继续保存并发送。✅ 传过去的expires(例如time() - 3600),且path、domain与设置时完全一致。
- ❌ 用 Cookie 保存用户 ID、角色、余额等有业务含义的字段 —— 全部可被用户随意修改。✅ 只存不透明的会话标识,状态放服务端;必须放客户端时用 HMAC 签名并在服务端校验。
- ❌ 不设置
samesite—— 省略时 PHP 不会输出这个属性,跨站请求会带上 Cookie,CSRF 面变大。✅ 显式写'samesite' => 'Lax'(或'Strict');确实需要'None'时必须同时'secure' => true,否则浏览器会直接拒绝这个 Cookie。
- ❌ 以为写入的值会原样存进浏览器 —— 值部分会被自动 URL 编码,读回来时由 PHP 自动解码赋给
$_COOKIE,前后看到的形态可能不同。✅ 需要完全不做编码地发送,用setrawcookie();但让 PHP 编码通常才是更稳的选择。
- ❌ 把 Cookie 当本地存储塞进大量数据 —— 浏览器对单个 Cookie 大小和单域名 Cookie 总量都有限制,且每个请求都会带上,直接反映在带宽上。✅ Cookie 里只放会话标识,其余数据留在服务端。
- ❌ 用
isset($_COOKIE['x'])判断「用户是否登出」 ——setcookie('x', '')之后$_COOKIE['x']是空字符串,isset()依然为真,判断方向正好反了。✅array_key_exists('x', $_COOKIE) && $_COOKIE['x'] !== '',并把「是否登录」这个判断放在服务端会话里,而不是靠 Cookie 是否存在。
总结
| 两种签名 | 位置参数;PHP 7.3+ 另有$options数组形式(不支持命名参数) |
| 调用时机 | 必须在任何输出之前,否则失败并返回false |
| 读取时机 | $_COOKIE只包含本次请求带来的 Cookie,新设的要下次请求才读得到 |
| 删除方法 | 过期时间设为过去,且path/domain与设置时一致 |
| 安全属性 | secure防明文链路,httponly防脚本读取,samesite缓解 CSRF |
| 存什么 | 只存不透明标识;有业务含义的数据必须服务端校验或 HMAC 签名 |
| 不存什么 | 密码、令牌明文、大量数据、任何可被篡改后直接影响授权的字段 |
一句话总结:setcookie()本质是「往响应头里加一行」,所以它受制于输出顺序;而 Cookie 本身是用户可读可改的客户端存储,把「不信任 Cookie 内容」和「关键状态放服务端」这两条守住,剩下的都只是属性配置问题。