A plugin with no admin screen

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   # correct

The 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.