In this piece
Your settings form offers three layouts: grid, list, and compact. The browser shows a select box, so it feels as though the server will receive one of those values. But a caller can send a different string, an array, or no value at all. The server needs its own rule.
I would make that rule a small function before wiring up the settings screen. This lesson implements and tests only the validation boundary. It does not register a setting or expose a save endpoint. The executable checks use WordPress 7.1.2, PHP 8.4.3, and WP-CLI on a disposable local installation.
The short version
- Define the exact values the feature understands.
- Reject unexpected types before using their values.
- Keep permission checks in the handler that performs the save.
Decide whether to accept or transform
An allowlist is a finite set of accepted values. For this setting, the contract is deliberately exact: lowercase grid, list, or compact. GRID is invalid. So is grid!. This follows the server-side approach described in WordPress’s validation guide.
sanitize_key() serves a different purpose: it lowercases a key and removes characters outside its permitted set. Passing GRID! through it produces grid. That can be a sensible normalization policy, but it would change this contract. I want malformed submissions to produce an error that the caller can correct.
Write the contract as a function
Save this as validate-layout.php outside the public web directory. The mixed parameter lets the function inspect unexpected types instead of allowing PHP to coerce them into a string. The return type says that the result is either a valid layout or a WordPress error object; this syntax requires PHP 8.0 or later.
<?php
function notebook_validate_layout(mixed $value): string|WP_Error {
$allowed = ['grid', 'list', 'compact'];
if (!is_string($value) || !in_array($value, $allowed, true)) {
return new WP_Error(
'invalid_layout',
'Choose grid, list, or compact.'
);
}
return $value;
}The third argument to in_array() enables strict comparison. The string check makes the accepted input shape obvious, and strict membership keeps both type and value relevant. A successful return preserves exactly what was submitted. There is no fallback that silently turns a failed request into grid.
A missing setting on a first visit may deserve a display default. A submitted invalid value deserves a validation response. Handle those two situations explicitly instead of letting one fallback hide both.
Run allowed and rejected inputs
Save this test next to the function. From the disposable WordPress installation, run wp eval-file /absolute/path/check-layout.php. WP-CLI loads WordPress, including WP_Error, before executing the file.
<?php
require __DIR__ . '/validate-layout.php';
foreach (['grid', 'list', 'compact'] as $valid) {
if (notebook_validate_layout($valid) !== $valid) {
throw new RuntimeException('Allowed value changed.');
}
}
$invalid = [null, [], true, 1, 'GRID', ' grid', 'grid!', 'unknown', ''];
foreach ($invalid as $value) {
$result = notebook_validate_layout($value);
if (!is_wp_error($result)
|| $result->get_error_code() !== 'invalid_layout') {
throw new RuntimeException('Invalid value accepted.');
}
}
if (sanitize_key('GRID!') !== 'grid') {
throw new RuntimeException('Unexpected sanitization result.');
}
echo "PASS: 3 accepted, 9 rejected, 1 sanitization comparison\n";Expect PASS: 3 accepted, 9 rejected, 1 sanitization comparison. The array represents a request such as layout[]=grid; it is not the same input as the string grid. Null represents a missing value at the function boundary. Neither reaches a database write, because this function has no write operation.
Connect it to a handler deliberately
When integrating this into an existing settings handler, validate the field after the request has been decoded. For a conventional WordPress form, account for WordPress’s slashed request values at that boundary. Do not apply form-specific unslashing indiscriminately to values already decoded from another transport, such as JSON.
Check is_wp_error($result) and stop before saving if validation fails. Authentication, a suitable capability check, and the request’s CSRF protection still belong in that handler. The existing nonce and permissions tutorial explains why one successful check cannot replace the others.
This is a pure validation exercise, so running it changes no settings. Before integrating it on staging, record the current setting and keep the previous handler file. A rollback should restore that file and, if integration tests changed the setting, restore its recorded value. Escaping is still needed when the accepted value is displayed; see escaping output at its destination.
Try it yourself
Add cards to the accepted layouts. First add it only to the test’s valid values: the test should fail. Then update the function and its error message; the test should pass. Add Cards to the rejected inputs and confirm that capitalization still matters.