Eleven settings screens across eight plugins on one site, most of them holding three or four values that have not changed since installation. A settings screen is what a plugin is expected to have, and on a site where configuration lives in a repository it is a form with exactly one possible user.
The symptom
$ wp option list --search='td_*' --format=table
+---------------------------+---------------------+
| option_name | option_value |
+---------------------------+---------------------+
| td_sync_settings | a:4:{s:8:"endpoint"...|
| td_sync_last_run | 1735689600 |
| td_export_settings | a:3:{s:6:"format"... |
+---------------------------+---------------------+
# three options, two of which are configuration and
# one of which is state. the same table, the same
# lifecycle, no distinction.and the settings screen behind td_sync_settings:
a menu registration 22 lines
a settings page callback 41
register_setting × 4 18
sanitisation callbacks × 4 36
a nonce, a capability check 8
the form markup 54
tests 0
179 lines, for four values, changed twice since 2023.Why it happens
A settings screen exists so somebody without deployment access can change something, and it gets built by default because that is what a plugin looks like. On a site where every change goes through a pipeline, the person it serves does not exist.
The fix
Configuration as code
// wp-config.php, or an environment-specific include
define( 'TD_SYNC_ENDPOINT', getenv( 'TD_SYNC_ENDPOINT' ) );
define( 'TD_SYNC_BATCH_SIZE', 500 );
// and the values that need logic rather than a literal
add_filter( 'td_sync_should_run', function ( bool $default ): bool {
return $default && 'production' === wp_get_environment_type();
} );
add_filter( 'td_sync_batch_size', function ( int $size ): int {
return turkerdev_is_peak_hours() ? 100 : $size;
} );
A constant for a value and a filter for a decision is the whole configuration surface, and the distinction matters — a batch size is a number and “smaller during peak hours” is logic, which a settings form cannot express without a second field and a checkbox.
The filter as an interface
## Filters
### `td_sync_should_run`
**Since** 1.0 · **Applied** before every scheduled run
```php
apply_filters( 'td_sync_should_run', bool ): bool
```
Return `false` to skip a run. `` is `true`
unless the plugin is disabled. Runs inside the cron
callback, so an expensive check costs a cron run.
### `td_sync_batch_size`
**Since** 1.2 · **Applied** once per run
Documenting a filter with a signature, a default and a note about when it runs is what makes it an interface rather than a hook somebody added. The “runs inside the cron callback” note is the one that matters — a filter is an extension point and its execution context is part of the contract.
Separating configuration from state
// state stays in the database, and is clearly not
// configuration
update_option( 'td_sync_last_run', time(), false );
// note the third argument: autoload false. state read
// once a day does not belong in the autoloaded set.
// and configuration is never an option:
// TD_SYNC_ENDPOINT a constant
// td_sync_batch_size a filter
// td_sync_last_run an option, because it is
// written by the plugin
The rule that came out of this is that an option is written by the plugin and a constant or filter is written by a human — which makes the options table state rather than a mixture, and makes it obvious that a migration between environments should not copy it.
Testing configuration that lives in a filter
public function test_the_sync_is_disabled_outside_production(): void {
add_filter( 'wp_get_environment_type', fn () => 'staging' );
$this->assertFalse( apply_filters( 'td_sync_should_run', true ) );
}
public function test_the_batch_size_is_reduced_at_peak(): void {
turkerdev_freeze_time( '2025-12-16 12:30:00' );
$this->assertSame( 100, apply_filters( 'td_sync_batch_size', 500 ) );
}
Testing a filter is testing the contract rather than the implementation, which is what makes it a genuine interface. The settings screen equivalent would need a form submission, a nonce, a capability check and a sanitiser tested separately — four tests for the machinery and none for the behaviour.
When a settings screen is right
a value a non-developer changes regularly
an editor toggling a promotional banner. yes,
build the screen.
a value that differs per environment
a constant, from the environment. never a screen,
because the value would then be in a database
that gets copied between environments.
a value changed once at installation
a constant. a screen for something set once is a
form nobody will find in three years.
a plugin distributed to people without a pipeline
a screen, obviously. this whole approach assumes
a deployment process.The environment case is the one worth being firm about: a setting in the database is a setting that travels with a database copy, so a staging environment restored from production points at the production endpoint. That has happened here and is the strongest argument against configuration-in-options.
Verifying it worked
$ wp option list --search='td_*' --format=table
+-------------------+------------+----------+
| option_name | value | autoload |
+-------------------+------------+----------+
| td_sync_last_run | 1766275200 | no |
+-------------------+------------+----------+
# one option, and it is state
$ git diff --stat
plugins/td-sync/admin/settings.php | 179 ---------
plugins/td-sync/src/Config.php | 22 +++
tests/Unit/ConfigTest.php | 41 +++
$ vendor/bin/phpunit --filter Config
Tests: 8 passed
# and the staging environment, restored from production
$ wp eval 'echo TD_SYNC_ENDPOINT;'
https://supplier-sandbox.example/v2 # correctThe staging environment pointing at the sandbox after a production database restore is the outcome that justifies the whole change, and it is a bug that had happened twice before. A constant from the environment cannot be copied by a database restore.
What this costs
A plugin that cannot be handed to a client without the pipeline. Everything here assumes a deployment process and a repository, and a site maintained by somebody logging into the admin would be unable to change anything — which is a complete non-starter for a distributed plugin and is exactly right for one built for a specific site.
It also puts configuration in a place that is invisible from the admin, so an administrator investigating behaviour has nowhere to look. A read-only settings screen showing the resolved values would fix that and would be a hundred lines of the machinery this exercise deleted — which is a trade that has not been made and probably should be.