チェックを外したチェックボックスは、フォームから何も送られません。値が 0 で送られるのではなく、キーそのものがありません。そのため、同じ設定を2つの画面のフォームから保存していると、スイッチの無いほうの画面で保存しただけで、その設定が切れることがあります。Chromium(Playwright)でフォームを送信し、PHP の $_POST に何が入るかを測りました。
| 書き方 | チェック | 送信された本文 | $_POST['s'] |
|---|---|---|---|
| A. チェックボックスだけ | する | s[other]=x&s[enabled]=1 |
{"other":"x","enabled":"1"} |
| 〃 | 外す | s[other]=x |
{"other":"x"}(キーが無い) |
| B. 同じ名前の hidden(値0)を前に置く | する | …&s[enabled]=0&s[enabled]=1 |
enabled: "1" |
| 〃 | 外す | …&s[enabled]=0 |
enabled: "0" |
| C. 同じ名前の hidden(値0)を後ろに置く | する | …&s[enabled]=1&s[enabled]=0 |
enabled: "0"(チェックしたのに0) |
| 〃 | 外す | …&s[enabled]=0 |
enabled: "0" |
D. 目印 s[_toggle]=1 を一緒に送る |
する | …&s[_toggle]=1&s[enabled]=1 |
_toggle: "1", enabled: "1" |
| 〃 | 外す | …&s[_toggle]=1 |
_toggle: "1"(enabled は無い) |
同じ名前が2回送られると、PHP は後ろの値で上書きします。B は、それを使って「外したら0」を作る書き方です。hidden を後ろに置いた C では、チェックしても0になりました。
先に断っておくと、チェックされていないチェックボックスが送信されないのは、HTML の仕様どおりです。フォームの送信内容を組み立てる手順(constructing the entry list)で、チェックされていないチェックボックスは飛ばされます。ここで書くのは、それを前提にせずに同じ設定を2つの画面に置いた自作の WordPress プラグインで何が起きたかと、書き方ごとに実際に送って測った結果です。
何が起きたか
自作の WordPress プラグイン Rapls AI Chatbot には、サイトの中身を読み込む「サイト学習」のオン・オフのスイッチがあります。読み込む仕組みは無料版に入っているのに、スイッチは Pro 版が出す画面にしかありませんでした。無料版で一度オフにすると、戻す場所がありません。
1.21.1 で、無料版の設定画面にもこのスイッチを置きました。すると、同じ crawler_enabled を、2つの画面のフォームが保存することになります。しかも、どちらのフォームも、ほかの設定と一緒に1つの配列(raplsaich_settings)として保存します。
保存する側の3つの書き方
保存のときに「キーが無い」をどう読むかで、結果が変わります。3通りを並べました。
$policies = [
'1 無ければ off'
=> fn($in, $saved) => !empty($in['enabled']),
'2 無ければ保存値のまま'
=> fn($in, $saved) => array_key_exists('enabled', $in) ? !empty($in['enabled']) : $saved,
'3 目印があれば、無い=off'
=> fn($in, $saved) => !empty($in['_toggle'])
? !empty($in['enabled'])
: (array_key_exists('enabled', $in) ? !empty($in['enabled']) : $saved),
];
保存値がオンの状態から、3つの操作をしたときの結果です。
| 操作 | 1 無ければ off | 2 無ければ保存値のまま | 3 目印で判断 |
|---|---|---|---|
| スイッチの画面でチェックを外して保存 | off | on のまま(切れない) | off |
| スイッチの画面でチェックして保存 | on | on | on |
| スイッチの無い別の画面で保存 | off(勝手に切れる) | on | on |
1 は、スイッチの無い画面で別の設定を保存しただけで、オフになります。2 は、その事故は起きませんが、スイッチの画面でチェックを外してもオフにできません。外したチェックボックスは何も送らないので、「外した」と「この画面にはスイッチが無い」が区別できないからです。
3 だけが、どちらも正しく扱えました。
1.21.1 の直し方
スイッチを置いた画面では、チェックボックスと一緒に、送られても保存はしない目印を送ります。
<input type="hidden" name="raplsaich_settings[_site_learning_toggle]" value="1">
<input type="checkbox" name="raplsaich_settings[crawler_enabled]" value="1"
<?php checked(!empty($settings['crawler_enabled'])); ?>>
保存の処理では、目印があるとき(Pro 版の画面では、その画面にしかない crawler_interval があるとき)だけ、「キーが無い=オフ」と読みます。どちらも無いフォームからの保存では、保存値を変えません。
$crawler_form_submitted = array_key_exists('crawler_interval', $input)
|| !empty($input['_site_learning_toggle']);
目印そのものは、保存する配列には入れません。
B(hidden を前に置く)では足りないのか
B でも、「外したら0」は送れます。1つの画面にしかスイッチが無いなら、B で十分です。
ただ、B が送るのは「この画面にはスイッチがあり、その値は0」という情報です。2つの画面のうち片方にしか B が無ければ、もう片方からの保存では、やはりキーが無いまま届きます。保存する側は結局、「キーが無いときにどうするか」を決めなければなりません。1.21.1 が選んだのは、目印で「この画面はスイッチを持っている」と明示する D でした。
もう1つ、B は hidden の位置を間違えると C になり、チェックしても0が保存されます。位置に意味を持たせる書き方は、あとから画面を直したときに壊れやすい。
チェックボックスを保存するとき
- チェックを外したチェックボックスは、キーごと送られない。
0は来ない - 同じ設定を複数の画面から保存するなら、「キーが無い」を、外したのか、その画面に無いのかで分ける
- 分けるには、スイッチを持つ画面だけが送る目印を足す
- 同じ名前の hidden で0を送る書き方は、hidden を前に置く。後ろに置くと、チェックしても0になる
あなたのプラグインに、同じ設定を2つのフォームから保存している画面はありませんか。
手元で試すコマンド
PHP のビルトインサーバーと Playwright で動きます。
<?php
// post.php:受け取った本文と $_POST をそのまま返す
header('Content-Type: application/json');
echo json_encode(['body' => file_get_contents('php://input'), 'post' => $_POST], JSON_UNESCAPED_UNICODE);
// run.js
const { chromium } = require('playwright');
const forms = {
'A そのまま': `<input type=checkbox name="s[enabled]" value=1 id=c>`,
'B hidden を前に': `<input type=hidden name="s[enabled]" value=0><input type=checkbox name="s[enabled]" value=1 id=c>`,
'C hidden を後ろに': `<input type=checkbox name="s[enabled]" value=1 id=c><input type=hidden name="s[enabled]" value=0>`,
'D 目印': `<input type=hidden name="s[_toggle]" value=1><input type=checkbox name="s[enabled]" value=1 id=c>`,
};
(async () => {
const b = await chromium.launch(); const p = await b.newPage();
for (const [name, inner] of Object.entries(forms)) for (const checked of [true, false]) {
await p.setContent(`<form method=post action="http://127.0.0.1:8765/post.php"><input type=hidden name="s[other]" value=x>${inner}<button id=go>保存</button></form>`);
if (checked) await p.check('#c');
const [res] = await Promise.all([p.waitForResponse(r => r.url().includes('post.php')), p.click('#go')]);
const j = await res.json();
console.log(name, checked ? 'チェック' : '外す', decodeURIComponent(j.body), JSON.stringify(j.post.s));
}
await b.close();
})();
php -S 127.0.0.1:8765 &
node run.js
ふだんはraplsworks.comで、WordPressプラグイン開発やClaude Codeまわりのことを書いています。
https://raplsworks.com/