string, // admin control label * 'tab' => string, // General|Content|Media|SEO|Settings * 'control' => string, // text|textarea|number|url|email|image|select|post * 'rest_type'=> string, // string|number|integer|boolean (default string) * 'default' => mixed, // default value (default '') * 'sanitize' => callable-string, // sanitize callback (default sanitize_text_field) * 'options' => array, // for control=select * 'post_type'=> string, // for control=post: the type listed; see post_field() * ] */ abstract class AbstractPostType { /** * The post type slug, e.g. `fxlc_service` (max 20 chars, fxlc_ prefixed). */ abstract public function type(): string; /** * @return array{singular:string,plural:string,menu?:string} */ abstract protected function labels(): array; /** * Meta fields for this type, keyed by unprefixed key. * * @return array> */ abstract public function fields(): array; /** * Per-type register_post_type overrides merged over the shared defaults. * * @return array */ protected function args(): array { return []; } public function register(): void { register_post_type( $this->type(), $this->build_args() ); $this->register_meta(); } /** * Fully-qualified meta key for an unprefixed field key. */ public function meta_key( string $field ): string { return 'fxlc_' . $field; } /** * A field that points at one post of another type, stored as its ID and * picked by name from a dropdown in the Details box. * * The sanitizer keeps an ID only when it is a post of that type, so a * typo or a page's ID saved through REST is stored as 0 ("none") instead * of quietly pointing somewhere else. * * @return array */ protected static function post_field( string $label, string $post_type, string $tab = 'Settings' ): array { return [ 'label' => $label, 'tab' => $tab, 'control' => 'post', 'post_type' => $post_type, 'rest_type' => 'integer', 'default' => 0, 'sanitize' => static function ( mixed $value ) use ( $post_type ): int { $id = is_numeric( $value ) ? absint( $value ) : 0; return $id > 0 && get_post_type( $id ) === $post_type ? $id : 0; }, ]; } /** * An image from the media library, stored as its attachment ID. * * @return array */ protected static function image_field( string $label, string $tab = 'Media' ): array { return [ 'label' => $label, 'tab' => $tab, 'control' => 'image', 'rest_type' => 'integer', 'default' => 0, 'sanitize' => static fn( $v ): int => absint( $v ), ]; } /** * A number that may have decimals: a price, a cost, a coordinate. * * @return array */ protected static function decimal_field( string $label, string $tab = 'General' ): array { return [ 'label' => $label, 'tab' => $tab, 'control' => 'number', 'rest_type' => 'number', 'default' => 0, 'sanitize' => static fn( $v ): float => (float) $v, ]; } /** * A link, kept only when it is a URL WordPress would print. * * @return array */ protected static function url_field( string $label, string $tab = 'General' ): array { return [ 'label' => $label, 'tab' => $tab, 'control' => 'url', 'sanitize' => 'esc_url_raw', ]; } /** * @return array */ private function build_args(): array { $labels = $this->labels(); $singular = $labels['singular']; $plural = $labels['plural']; $defaults = [ 'labels' => [ 'name' => $plural, 'singular_name' => $singular, /* translators: %s: singular post type label. */ 'add_new_item' => sprintf( __( 'Add New %s', 'flexa-local-services' ), $singular ), /* translators: %s: singular post type label. */ 'edit_item' => sprintf( __( 'Edit %s', 'flexa-local-services' ), $singular ), /* translators: %s: singular post type label. */ 'new_item' => sprintf( __( 'New %s', 'flexa-local-services' ), $singular ), /* translators: %s: singular post type label. */ 'view_item' => sprintf( __( 'View %s', 'flexa-local-services' ), $singular ), /* translators: %s: plural post type label. */ 'view_items' => sprintf( __( 'View %s', 'flexa-local-services' ), $plural ), /* translators: %s: plural post type label. */ 'search_items' => sprintf( __( 'Search %s', 'flexa-local-services' ), $plural ), // The plural keeps its case: lower-casing it breaks names like "FAQs". /* translators: %s: plural post type label. */ 'not_found' => sprintf( __( 'No %s found.', 'flexa-local-services' ), $plural ), /* translators: %s: plural post type label. */ 'not_found_in_trash' => sprintf( __( 'No %s found in Trash.', 'flexa-local-services' ), $plural ), 'all_items' => $plural, 'menu_name' => $labels['menu'] ?? $plural, ], 'public' => true, 'show_ui' => true, 'show_in_menu' => 'flexa-local', 'show_in_rest' => true, 'has_archive' => true, 'hierarchical' => false, 'menu_position' => null, 'supports' => [ 'title', 'editor', 'thumbnail', 'excerpt' ], 'rewrite' => [ 'slug' => str_replace( 'fxlc_', '', $this->type() ) ], ]; $overrides = $this->args(); /** @var array $args */ $args = array_replace_recursive( $defaults, $overrides ); // A list such as `supports` replaces the default one whole. Merged by // index, a shorter list would keep the default's tail entries. foreach ( $overrides as $key => $value ) { if ( is_array( $value ) && array_values( $value ) === $value ) { $args[ $key ] = $value; } } return $args; } private function register_meta(): void { foreach ( $this->fields() as $field => $config ) { $rest_type = is_string( $config['rest_type'] ?? null ) ? $config['rest_type'] : 'string'; $sanitize = is_callable( $config['sanitize'] ?? null ) ? $config['sanitize'] : 'sanitize_text_field'; register_post_meta( $this->type(), $this->meta_key( (string) $field ), [ 'type' => $rest_type, 'single' => true, 'default' => $config['default'] ?? '', 'show_in_rest' => true, 'sanitize_callback' => $sanitize, /* * WordPress passes the post this meta belongs to, so the * answer can be about that post instead of the plugin in * general. Core checks `edit_post` before it gets here on * its own routes; saying so again costs nothing and holds * for anything that writes meta by another path. */ 'auth_callback' => static fn( bool $allowed, string $meta_key, int $object_id ): bool => Capabilities::can_manage() && current_user_can( 'edit_post', $object_id ), ] ); } } }