PHP Enum tryFrom Example: Validate WordPress Settings

Looking for a PHP enum tryFrom example that does real work in WordPress? Here is one. A WordPress setting with a fixed list of choices, such as an archive layout of grid, compact or masonry, is usually built from loose strings. The allowed values get typed into the <select>, into the sanitize callback, into the REST schema and into every if that reads the option. When one copy changes and the others don’t, a typo or a hand-edited database row slips through and your template quietly falls back to whatever the else branch does.

The snippet below keeps the list in one place: a backed enum. tryFrom() checks every value coming in, match gives each case its label, and the rest of your plugin gets a typed dpc_Archive_Layout object instead of a string it has to check again. Add a case and the dropdown, the sanitizer and the REST schema all pick it up.

The snippet

Put this in a small plugin (it needs PHP 8.1 or newer). It adds an Archive layout dropdown to Settings → Reading and puts a dpc-layout-{value} class on the body of archive pages and the blog page.

<?php
/**
 * Plugin Name: DPC Archive Layout Setting
 * Description: A validated select setting backed by a PHP enum.
 * Requires PHP: 8.1
 */

defined( 'ABSPATH' ) || exit;

enum dpc_Archive_Layout: string {
	case Grid    = 'grid';
	case Compact = 'compact';
	case Masonry = 'masonry';

	public static function fallback(): self {
		return self::Grid;
	}

	public function label(): string {
		return match ( $this ) {
			self::Grid    => __( 'Grid', 'dpc' ),
			self::Compact => __( 'Compact list', 'dpc' ),
			self::Masonry => __( 'Masonry', 'dpc' ),
		};
	}

	// Never throws: unknown or non-string input becomes the fallback case.
	public static function fromMixed( mixed $value ): self {
		if ( ! is_string( $value ) ) {
			return self::fallback();
		}
		return self::tryFrom( sanitize_key( $value ) ) ?? self::fallback();
	}

	public static function sanitize( mixed $value ): string {
		return self::fromMixed( $value )->value;
	}

	public static function values(): array {
		return array_map( static fn( self $layout ): string => $layout->value, self::cases() );
	}
}

function dpc_get_archive_layout(): dpc_Archive_Layout {
	return dpc_Archive_Layout::fromMixed( get_option( 'dpc_archive_layout' ) );
}

add_action( 'init', static function (): void {
	register_setting( 'reading', 'dpc_archive_layout', [
		'type'              => 'string',
		'default'           => dpc_Archive_Layout::fallback()->value,
		'sanitize_callback' => dpc_Archive_Layout::sanitize(...),
		'show_in_rest'      => [
			'schema' => [ 'enum' => dpc_Archive_Layout::values() ],
		],
	] );
} );

add_action( 'admin_init', static function (): void {
	add_settings_field(
		'dpc_archive_layout',
		__( 'Archive layout', 'dpc' ),
		'dpc_render_archive_layout_field',
		'reading',
		'default',
		[ 'label_for' => 'dpc_archive_layout' ]
	);
} );

function dpc_render_archive_layout_field(): void {
	$current = dpc_get_archive_layout();

	echo '<select name="dpc_archive_layout" id="dpc_archive_layout">';
	foreach ( dpc_Archive_Layout::cases() as $layout ) {
		printf(
			'<option value="%s"%s>%s</option>',
			esc_attr( $layout->value ),
			selected( $current->value, $layout->value, false ),
			esc_html( $layout->label() )
		);
	}
	echo '</select>';
}

add_filter( 'body_class', static function ( array $classes ): array {
	if ( is_home() || is_archive() ) {
		$classes[] = 'dpc-layout-' . dpc_get_archive_layout()->value;
	}
	return $classes;
} );

How it works

  • dpc_Archive_Layout is a backed enum. Each case has a string value, and that string is what gets saved in wp_options.
  • tryFrom() returns the matching case, or null if nothing matches. from() would throw a ValueError instead. When the input comes from users or the database, you want null, and ?? self::fallback() turns it into a safe default.
  • fromMixed() runs sanitize_key() first, so MASONRY or masonry still match. Arrays, integers and false (what get_option() returns for a missing option) fall back to Grid.
  • dpc_Archive_Layout::sanitize(...) is a first-class callable. It makes a Closure, so you don’t have to write the class and method name as strings. WordPress runs it through the sanitize_option_dpc_archive_layout filter on every save, whether the value comes from the form, update_option(), WP-CLI or REST.
  • label() uses match. If you add a case and forget its label, PHP throws an UnhandledMatchError instead of showing an empty option.
  • The form is posted to options.php through the Reading screen, so the Settings API does the nonce check and the manage_options capability check for you. Values are escaped on output with esc_attr() and esc_html().

Customise it

  • Add a layout by adding one case, plus its line in label(). The dropdown, the sanitizer and the REST enum schema update on their own.
  • Put more behaviour on the enum, for example public function columns(): int with its own match, so templates call dpc_get_archive_layout()->columns() instead of switching on strings.
  • To give the setting its own page, change 'reading' to your page slug in both register_setting() and add_settings_field(). The Developer Notes admin page post shows a starting point for a custom screen.

Gotchas

  • Never pass the enum object itself to selected(), checked() or esc_attr(). They cast to string, and enums can’t be cast, so you get a fatal Error. Pass ->value.
  • Don’t save the enum object with update_option(). Save ->value and rebuild the object when you read it back. Otherwise the object gets serialized into the database.
  • A closure from sanitize(...) can’t be removed later with remove_filter(), because you don’t have the same instance. That’s fine for your own plugin, but use a named function if other code needs to unhook it. See how to find every callback hooked to a filter when you debug this.
  • Don’t call WordPress core functions with named arguments. Core doesn’t treat parameter names as a stable API, so a rename in a future release could break your call.
  • Declaring an enum is a parse error on PHP 8.0 and older, which takes the whole site down. Keep the Requires PHP: 8.1 header so WordPress refuses to activate the plugin on old hosts.

Snippet poll. Should this snippet be in the Scripts Organizer library for one-click import? 10 votes put it there.

0 of 10 votes

// newsletter

Three tested WordPress snippets a week

Blocks, security, performance, Tailwind, PHP and React. Copy, paste, ship. You’ll also get DPlugins product updates. No spam, and you can unsubscribe anytime.

// newsletter

Three tested WordPress snippets a week

Blocks, security, performance, Tailwind, PHP and React. Copy, paste, ship. You'll also get DPlugins product updates. No spam, and you can unsubscribe anytime.