From 947d4f5a6cbb2f03966d214980618ffd0e73fc6b Mon Sep 17 00:00:00 2001 From: Zachary Hickson Date: Tue, 12 May 2026 23:31:21 +1000 Subject: [PATCH 01/14] Fix four bugs in CommentModelFactory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Typo $commend → $comment caused undefined variable return from get_by_id() - update_comment() discarded get_by_id() result, always returned null - create_comment() called set_comment_meta() without the required $comment argument - delete_comment() used $comment->ID instead of $comment->comment_ID Co-Authored-By: Claude Sonnet 4.6 --- src/Model/CommentModelFactory.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Model/CommentModelFactory.php b/src/Model/CommentModelFactory.php index 064153a..bfca362 100644 --- a/src/Model/CommentModelFactory.php +++ b/src/Model/CommentModelFactory.php @@ -27,7 +27,7 @@ class CommentModelFactory extends WPModelFactory { * @return null|CommentModel */ public function get_by_id( $id ) { - $commend = null; + $comment = null; $wp_comment = get_comment( $id ); if ( ! empty( $wp_comment ) ) { @@ -114,7 +114,7 @@ public function create_comment( $insert_comment_args, $meta_fields = [] ) { // Set comment meta data. if ( ! empty( $meta_fields ) ) { - $this->set_comment_meta( $meta_fields ); + $this->set_comment_meta( $comment, $meta_fields ); } } @@ -146,7 +146,7 @@ public function update_comment( $comment, $comment_args = [] ) { $outcome = wp_update_comment( $comment_args ); if ( 1 === $outcome ) { - $this->get_by_id( $comment->comment_ID ); + $updated_comment = $this->get_by_id( $comment->comment_ID ); } return $updated_comment; @@ -161,7 +161,7 @@ public function update_comment( $comment, $comment_args = [] ) { * @return bool. */ public function delete_comment( $comment, $force_delete = false ) { - return $this->delete_comment_by_id( $comment->ID, $force_delete ); + return $this->delete_comment_by_id( $comment->comment_ID, $force_delete ); } /** From a9352364c682f988f25e83b2cfb0063978bc3394 Mon Sep 17 00:00:00 2001 From: Zachary Hickson Date: Tue, 12 May 2026 23:31:54 +1000 Subject: [PATCH 02/14] Fix Config::get() default value and glob() safety - get() previously overrode \$default immediately with the empty config array; now returns \$default correctly when the config name does not exist - glob() can return false on failure; use ?: [] to ensure a safe iterable - Sanitise WP_ENV before interpolating into a filesystem glob path Co-Authored-By: Claude Sonnet 4.6 --- src/Library/Config.php | 28 +++++++++------------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/src/Library/Config.php b/src/Library/Config.php index ed7123f..f092a86 100644 --- a/src/Library/Config.php +++ b/src/Library/Config.php @@ -63,24 +63,17 @@ public function __construct( \WPMVC\Core\Application $app ) { */ public function get( $name, $key = '', $default = [] ) { - $return_value = $default; + $config_array = $this->config[ $name ] ?? null; - // Retrieve the single configuration array. - $config_array = []; - if ( isset( $this->config[ $name ] ) ) { - $config_array = $this->config[ $name ]; + if ( null === $config_array ) { + return $default; } - // Return entire config array by default. - $return_value = $config_array; - - // Return config item if key has been passed to us. if ( ! empty( $key ) ) { - $config_scalar = isset( $config_array[ $key ] ) ? $config_array[ $key ] : $default; - $return_value = $config_scalar; + return $config_array[ $key ] ?? $default; } - return $return_value; + return $config_array; } /** @@ -114,7 +107,7 @@ public function autoload() { $dir = $this->app->get_directory(); // Load the main configuration merging the default variables when required. - $config_files = glob( "$dir/config/*.php" ); + $config_files = glob( "$dir/config/*.php" ) ?: []; foreach ( $config_files as $config_file ) { $config_name = basename( $config_file, '.php' ); $app_config[ $config_name ] = include $config_file; @@ -124,16 +117,13 @@ public function autoload() { $env_config_glob = "$dir/config/local/*.php"; // By default assume local dev. if ( defined( 'WP_ENV' ) ) { - $env_config_glob = sprintf( - '%s/config/%s/*.php', - $dir, - WP_ENV - ); + $env_name = preg_replace( '/[^a-zA-Z0-9_-]/', '', WP_ENV ); + $env_config_glob = sprintf( '%s/config/%s/*.php', $dir, $env_name ); } // Load each of the environment specific config files. - $config_files = glob( $env_config_glob ); + $config_files = glob( $env_config_glob ) ?: []; foreach ( $config_files as $config_file ) { $config_name = basename( $config_file, '.php' ); $env_config[ $config_name ] = include $config_file; From ac84f55373d223d9c8c0362925a99be6fd5e8e95 Mon Sep 17 00:00:00 2001 From: Zachary Hickson Date: Tue, 12 May 2026 23:32:23 +1000 Subject: [PATCH 03/14] Fix Route request URI handling and match check - Sanitise REQUEST_URI with wp_unslash/sanitize_url before use - Replace strtok() (stateful) with parse_url() to safely strip query string - Check the return value of preg_match() directly instead of checking whether the \$matches array is non-empty; the old check silently swallowed regex errors Co-Authored-By: Claude Sonnet 4.6 --- src/Library/Route.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Library/Route.php b/src/Library/Route.php index 4b06d19..e5ea803 100644 --- a/src/Library/Route.php +++ b/src/Library/Route.php @@ -83,9 +83,9 @@ public function add( array $args ) { public function handle_routes( $continue, $wp, $extra_query_vars ) { // Get the request path / URI. - $request_path = isset( $_SERVER['REQUEST_URI'] ) ? $_SERVER['REQUEST_URI'] : ''; // phpcs:ignore + $request_path = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_url( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput + $request_path = parse_url( $request_path, PHP_URL_PATH ) ?? ''; $request_path = trim( $request_path, '/' ); - $request_path = strtok( $request_path, '?' ); // Make request path relative to the site path. // This is required for routes to work when WP is installed in a subdirectory. @@ -107,7 +107,7 @@ public function handle_routes( $continue, $wp, $extra_query_vars ) { $match = preg_match( '{' . $regex . '}', $request_path, $matches ); // Dispatch route if a hit. - if ( ! empty( $matches ) ) { + if ( $match ) { // Initialsie the admin bar, so we have admin bar access on custom routes! if ( is_user_logged_in() ) { From 4ba7c9c0774723e90ac63578260a4ad091d098ea Mon Sep 17 00:00:00 2001 From: Zachary Hickson Date: Tue, 12 May 2026 23:32:53 +1000 Subject: [PATCH 04/14] Fix REST: remove extract() and warn on missing permission_callback - Replace extract() in register_endpoints() and build_route() with explicit array key access; extract() on arbitrary data pollutes the local namespace - Emit _doing_it_wrong() when an endpoint is registered without a permission_callback so developers are notified rather than silently getting public access by default Co-Authored-By: Claude Sonnet 4.6 --- src/Library/REST.php | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/src/Library/REST.php b/src/Library/REST.php index 0c4e377..4ab24ab 100644 --- a/src/Library/REST.php +++ b/src/Library/REST.php @@ -123,23 +123,33 @@ public function get_request() { public function register_endpoints() { foreach ( $this->endpoints as $endpoint ) { - extract( $endpoint ); // phpcs:ignore + + if ( empty( $endpoint['permission_callback'] ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: %s: REST endpoint action */ + esc_html__( 'REST endpoint "%s" has no permission_callback. Pass an explicit callback or __return_true to allow public access.', 'wpmvc' ), + esc_html( $endpoint['action'] ) + ), + '1.0.0' + ); + } // Build the actual namespace. - $namespace = "{$namespace}/{$version}"; + $namespace = $endpoint['namespace'] . '/' . $endpoint['version']; // Register the endpoint. register_rest_route( $namespace, - $action, + $endpoint['action'], [ - 'methods' => $method, + 'methods' => $endpoint['method'], 'callback' => [ $this, 'handle_callback' ], 'permission_callback' => [ $this, 'handle_perm_callback' ], - 'args' => $args, + 'args' => $endpoint['args'], ] ); - } } @@ -250,12 +260,6 @@ public function handle_callback( \WP_REST_Request $request ) { * @return string */ private static function build_route( array $endpoint ) { - $route = ''; - - extract( $endpoint ); // phpcs:ignore - - $route = "/{$namespace}/{$version}/{$action}"; - - return $route; + return '/' . $endpoint['namespace'] . '/' . $endpoint['version'] . '/' . $endpoint['action']; } } From b0873a4ecba3e11038684f1b6ca8dc49e6fc2c28 Mon Sep 17 00:00:00 2001 From: Zachary Hickson Date: Tue, 12 May 2026 23:33:28 +1000 Subject: [PATCH 05/14] Add \$auth_only parameter to AdminAjax and verify_nonce() helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - endpoint() now accepts an optional \$auth_only flag (default false); when true, only the wp_ajax_ hook is registered so the endpoint is restricted to logged-in users — existing callers are unaffected - Add static verify_nonce() helper so callbacks have a one-liner to validate nonces and die with 403 on failure Co-Authored-By: Claude Sonnet 4.6 --- src/Library/AdminAjax.php | 34 ++++++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/src/Library/AdminAjax.php b/src/Library/AdminAjax.php index d7b522e..e245016 100644 --- a/src/Library/AdminAjax.php +++ b/src/Library/AdminAjax.php @@ -21,13 +21,19 @@ class AdminAjax { /** * Register a new AJAX endpoint. * - * @param string $action The ajax action name. - * @param array $callback The callback for the ajax hook. + * @param string $action The ajax action name. + * @param callable $callback The callback for the ajax hook. + * @param bool $auth_only When true, only the wp_ajax_ hook is registered so the + * endpoint is restricted to logged-in users. Default false + * registers both authenticated and unauthenticated hooks. */ - public function endpoint( $action, $callback ) { + public function endpoint( $action, $callback, $auth_only = false ) { add_action( 'wp_ajax_' . $action, $callback ); - add_action( 'wp_ajax_nopriv_' . $action, $callback ); + + if ( ! $auth_only ) { + add_action( 'wp_ajax_nopriv_' . $action, $callback ); + } } /** @@ -40,9 +46,25 @@ public function endpoint( $action, $callback ) { */ public static function get_param( $param, $default = '' ) { - $value = isset( $_POST[ $param ] ) ? $_POST[ $param ] : $default; // phpcs:ignore + return isset( $_POST[ $param ] ) ? $_POST[ $param ] : $default; // phpcs:ignore WordPress.Security.NonceVerification,WordPress.Security.ValidatedSanitizedInput + } + + /** + * Verify a nonce sent with an AJAX request. + * Call this at the top of any AJAX callback that modifies data. + * + * @param string $action The nonce action string used when the nonce was created. + * @param string $nonce_key The POST/GET key holding the nonce value. Default '_wpnonce'. + * + * @return void Calls wp_die() with a 403 response on failure. + */ + public static function verify_nonce( $action, $nonce_key = '_wpnonce' ) { + + $nonce = isset( $_REQUEST[ $nonce_key ] ) ? sanitize_text_field( wp_unslash( $_REQUEST[ $nonce_key ] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification - return $value; + if ( ! wp_verify_nonce( $nonce, $action ) ) { + wp_die( esc_html__( 'Security check failed.', 'wpmvc' ), 403 ); + } } /** From 0f0eb0ac6e1f99b8dcc01f4d2a487b26c4dd9a1d Mon Sep 17 00:00:00 2001 From: Zachary Hickson Date: Tue, 12 May 2026 23:33:50 +1000 Subject: [PATCH 06/14] Fix EmailView filter leak and preg_replace backreference injection - wp_mail filters were registered anew on every EmailView instantiation and could never be removed; use a static flag so they are registered once - shortcodes() used preg_replace() where the replacement string is parsed for \$1/\\1 backreferences; replace with preg_replace_callback() returning the value via closure so special characters in parameter values are treated literally Co-Authored-By: Claude Sonnet 4.6 --- src/View/EmailView.php | 46 +++++++++++++++++++++++++++--------------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/src/View/EmailView.php b/src/View/EmailView.php index 6f713e7..23ae2ce 100644 --- a/src/View/EmailView.php +++ b/src/View/EmailView.php @@ -59,6 +59,13 @@ class EmailView extends View { */ protected $attachments = []; + /** + * Whether the mail filters have been registered yet. + * + * @var bool + */ + private static $filters_registered = false; + /** * Constructor. * @@ -70,20 +77,24 @@ public function __construct( Config $config, $template ) { $this->config = $config; $this->template = $template; - // Force html content type for emails. - add_filter( - 'wp_mail_content_type', - function () { - return 'text/html'; - } - ); + self::register_mail_filters(); + } - add_filter( - 'wp_mail_charset', - function () { - return 'UTF-8'; - } - ); + /** + * Register wp_mail filters once per process, regardless of how many EmailView instances are created. + * + * @return void + */ + private static function register_mail_filters() { + + if ( self::$filters_registered ) { + return; + } + + add_filter( 'wp_mail_content_type', static function () { return 'text/html'; } ); + add_filter( 'wp_mail_charset', static function () { return 'UTF-8'; } ); + + self::$filters_registered = true; } /** @@ -172,9 +183,12 @@ protected function shortcodes( $content ) { if ( is_string( $value ) ) { - $content = preg_replace( - '{{{(| )' . $key . '( |)}}}', - $value, + $replacement = $value; // Capture for use inside closure. + $content = preg_replace_callback( + '{{{(| )' . preg_quote( $key, '{' ) . '( |)}}}', + static function () use ( $replacement ) { + return $replacement; + }, $content ); From b6cf737ff7c6dcdb2075b35080a31a946ba78064 Mon Sep 17 00:00:00 2001 From: Zachary Hickson Date: Tue, 12 May 2026 23:34:17 +1000 Subject: [PATCH 07/14] Replace gettype() checks with instanceof across all models gettype() === 'object' accepts any object; instanceof checks the exact expected type and is more readable and type-safe. Co-Authored-By: Claude Sonnet 4.6 --- src/Model/CommentModel.php | 2 +- src/Model/GenericPostModel.php | 4 ++-- src/Model/TaxonomyModel.php | 2 +- src/Model/TaxonomyTermModel.php | 2 +- src/Model/UserModel.php | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Model/CommentModel.php b/src/Model/CommentModel.php index 84e751c..1216182 100644 --- a/src/Model/CommentModel.php +++ b/src/Model/CommentModel.php @@ -29,7 +29,7 @@ class CommentModel extends WPModel implements WPMeta { */ public function __construct( $comment = 0 ) { - if ( 'object' === (string) gettype( $comment ) ) { + if ( $comment instanceof \WP_Comment ) { $this->comment = $comment; } else { $this->comment = get_comment( $comment ); diff --git a/src/Model/GenericPostModel.php b/src/Model/GenericPostModel.php index 271e509..a16f83f 100644 --- a/src/Model/GenericPostModel.php +++ b/src/Model/GenericPostModel.php @@ -98,8 +98,8 @@ class GenericPostModel extends WPModel implements WPMeta, WPTaxonomyTerms { */ public function __construct( $post = null ) { - // If an ID was passed, retrieved the post object. - if ( 'object' !== (string) gettype( $post ) ) { + // If an ID was passed, retrieve the post object. + if ( ! ( $post instanceof \WP_Post ) ) { $post = get_post( $post ); } diff --git a/src/Model/TaxonomyModel.php b/src/Model/TaxonomyModel.php index 7352c3c..aca2739 100644 --- a/src/Model/TaxonomyModel.php +++ b/src/Model/TaxonomyModel.php @@ -31,7 +31,7 @@ class TaxonomyModel extends WPModel { public function __construct( $taxonomy = null ) { // assert( ! empty( $taxonomy ) ); - if ( 'object' === (string) gettype( $taxonomy ) ) { + if ( $taxonomy instanceof \WP_Taxonomy ) { $this->taxonomy = $taxonomy; } else { $this->taxonomy = get_taxonomy( $taxonomy ); diff --git a/src/Model/TaxonomyTermModel.php b/src/Model/TaxonomyTermModel.php index a802f97..0ae826a 100644 --- a/src/Model/TaxonomyTermModel.php +++ b/src/Model/TaxonomyTermModel.php @@ -48,7 +48,7 @@ class TaxonomyTermModel extends WPModel implements WPMeta { public function __construct( $term = 0 ) { // assert( ! empty( $term ) ); - if ( 'object' === (string) gettype( $term ) ) { + if ( $term instanceof \WP_Term ) { $this->term = $term; } else { $this->term = get_term( $term ); diff --git a/src/Model/UserModel.php b/src/Model/UserModel.php index bb6796b..82ed059 100644 --- a/src/Model/UserModel.php +++ b/src/Model/UserModel.php @@ -57,7 +57,7 @@ class UserModel extends WPModel implements WPMeta { public function __construct( $user = 0 ) { // assert( ! empty( $user ) ); - if ( 'object' === (string) gettype( $user ) ) { + if ( $user instanceof \WP_User ) { $this->user = $user; } else { $this->user = get_user_by( 'id', $user ); From ab93788a5923a955e9b130de807635ca2d9a415b Mon Sep 17 00:00:00 2001 From: Zachary Hickson Date: Tue, 12 May 2026 23:34:56 +1000 Subject: [PATCH 08/14] Replace isset/ternary patterns with null coalescing operator Use ?? throughout rather than the verbose isset() ? x : y form. Also simplify Templater constructor by assigning directly to properties. Co-Authored-By: Claude Sonnet 4.6 --- src/Library/Config.php | 4 ++-- src/Library/REST.php | 23 ++++++++++++----------- src/Library/Route.php | 2 +- src/Library/Templater.php | 22 ++++------------------ 4 files changed, 19 insertions(+), 32 deletions(-) diff --git a/src/Library/Config.php b/src/Library/Config.php index f092a86..b446723 100644 --- a/src/Library/Config.php +++ b/src/Library/Config.php @@ -136,8 +136,8 @@ public function autoload() { foreach ( $all_config_keys as $config_key ) { - $app_config_value = isset( $app_config[ $config_key ] ) ? $app_config[ $config_key ] : []; - $env_config_value = isset( $env_config[ $config_key ] ) ? $env_config[ $config_key ] : []; + $app_config_value = $app_config[ $config_key ] ?? []; + $env_config_value = $env_config[ $config_key ] ?? []; $this->config[ $config_key ] = array_replace_recursive( $app_config_value, $env_config_value ); diff --git a/src/Library/REST.php b/src/Library/REST.php index 4ab24ab..9781b46 100644 --- a/src/Library/REST.php +++ b/src/Library/REST.php @@ -89,17 +89,18 @@ public function __construct() { */ public function endpoint( array $args ) { - $default_args = [ - 'namespace' => '', - 'version' => 'v1', - 'action' => '', - 'method' => 'GET', - 'callback' => '', - 'permission_callback' => '', - 'args' => [], - ]; - - $args = array_merge( $default_args, $args ); + $args = array_merge( + [ + 'namespace' => '', + 'version' => 'v1', + 'action' => '', + 'method' => 'GET', + 'callback' => '', + 'permission_callback' => '', + 'args' => [], + ], + $args + ); // Add to the list of endpoints to register. $route = $this->build_route( $args ); diff --git a/src/Library/Route.php b/src/Library/Route.php index e5ea803..1410c19 100644 --- a/src/Library/Route.php +++ b/src/Library/Route.php @@ -92,7 +92,7 @@ public function handle_routes( $continue, $wp, $extra_query_vars ) { $site_url = get_site_url(); $site_url_parts = wp_parse_url( $site_url ); - $site_url_path = isset( $site_url_parts['path'] ) ? $site_url_parts['path'] : '/'; + $site_url_path = $site_url_parts['path'] ?? '/'; $site_url_path = trim( $site_url_path, '/' ); $request_path = preg_replace( '{^/?' . $site_url_path . '/?}', '', $request_path ); diff --git a/src/Library/Templater.php b/src/Library/Templater.php index 16ac66c..15d1415 100644 --- a/src/Library/Templater.php +++ b/src/Library/Templater.php @@ -92,24 +92,10 @@ class Templater { */ public function __construct( array $args ) { - $slug = isset( $args['slug'] ) ? $args['slug'] : ''; - $dir = isset( $args['dir'] ) ? $args['dir'] : ''; - $subdir = isset( $args['subdir'] ) ? $args['subdir'] : ''; - $params = isset( $args['params'] ) ? $args['params'] : ''; - - // assert( ! empty( $slug ) ); - $this->slug = $slug; - - // assert( ! empty( $dir ) ); - $this->dir = $dir; - - $this->subdir = $subdir; - - if ( empty( $params ) ) { - $this->params = []; - } else { - $this->params = $params; - } + $this->slug = $args['slug'] ?? ''; + $this->dir = $args['dir'] ?? ''; + $this->subdir = $args['subdir'] ?? ''; + $this->params = $args['params'] ?? []; // Add in the template slug. $this->params['template_slug'] = $slug; From 63dbad4368f74b7dfd859bb38d1ee6754251b45a Mon Sep 17 00:00:00 2001 From: Zachary Hickson Date: Tue, 12 May 2026 23:37:03 +1000 Subject: [PATCH 09/14] Extract wrap_models() and apply_meta_fields() to WPModelFactory base Both methods were copy-pasted verbatim into all four concrete factories. Defining them once in WPModelFactory eliminates the duplication; each factory's set_*_meta() method now delegates to apply_meta_fields(). Also declare abstract wrap() on WPModelFactory to enforce the contract. Co-Authored-By: Claude Sonnet 4.6 --- src/Model/CommentModelFactory.php | 32 +---------------- src/Model/GenericPostModelFactory.php | 32 +---------------- src/Model/TaxonomyTermModelFactory.php | 32 +---------------- src/Model/UserModelFactory.php | 32 +---------------- src/Model/WPModelFactory.php | 49 ++++++++++++++++++++++++++ 5 files changed, 53 insertions(+), 124 deletions(-) diff --git a/src/Model/CommentModelFactory.php b/src/Model/CommentModelFactory.php index bfca362..9d12b27 100644 --- a/src/Model/CommentModelFactory.php +++ b/src/Model/CommentModelFactory.php @@ -183,37 +183,7 @@ public function delete_comment_by_id( $comment_id, $force_delete = false ) { * @param array $meta_fields The key/value meta data. */ public function set_comment_meta( $comment, $meta_fields ) { - foreach ( $meta_fields as $key => $value ) { - if ( empty( $value ) ) { - $comment->delete_meta( $key ); - } elseif ( is_array( $value ) ) { - $comment->delete_meta( $key ); - - // Arrays are added as multiple, separate values. - foreach ( $value as $val ) { - $comment->add_meta( $key, $val ); - } - } else { - $comment->set_meta( $key, $value ); - } - } - } - - /** - * Wrap the given WP model instances. - * - * @param array $wp_models WordPress model instances. - * - * @return array - */ - public function wrap_models( $wp_models ) { - $models = []; - - foreach ( $wp_models as $wp_model ) { - $models[] = $this->wrap( $wp_model ); - } - - return $models; + $this->apply_meta_fields( $comment, $meta_fields ); } /** diff --git a/src/Model/GenericPostModelFactory.php b/src/Model/GenericPostModelFactory.php index 4dae1b8..3a27289 100644 --- a/src/Model/GenericPostModelFactory.php +++ b/src/Model/GenericPostModelFactory.php @@ -362,37 +362,7 @@ public function delete_post_by_id( $post_id, $force_delete = false ) { * @param array $meta_fields The key/value meta data. */ public function set_post_meta( $post, $meta_fields ) { - foreach ( $meta_fields as $key => $value ) { - if ( empty( $value ) ) { - $post->delete_meta( $key ); - } elseif ( is_array( $value ) ) { - $post->delete_meta( $key ); - - // Arrays will be added as multiple, separate values. - foreach ( $value as $val ) { - $post->add_meta( $key, $val ); - } - } else { - $post->set_meta( $key, $value ); - } - } - } - - /** - * Wrap the given WP model instances. - * - * @param array $wp_models WordPress model instances. - * - * @return array - */ - public function wrap_models( $wp_models ) { - $models = array(); - - foreach ( $wp_models as $wp_model ) { - $models[] = $this->wrap( $wp_model ); - } - - return $models; + $this->apply_meta_fields( $post, $meta_fields ); } /** diff --git a/src/Model/TaxonomyTermModelFactory.php b/src/Model/TaxonomyTermModelFactory.php index 0347e10..f964d6e 100644 --- a/src/Model/TaxonomyTermModelFactory.php +++ b/src/Model/TaxonomyTermModelFactory.php @@ -351,37 +351,7 @@ public function delete_term_by_id( $term_id, $taxonomy, $args = [] ) { * @param array $meta_fields The key/value meta data. */ public function set_term_meta( $term, $meta_fields ) { - foreach ( $meta_fields as $key => $value ) { - if ( empty( $value ) ) { - $term->delete_meta( $key ); - } elseif ( is_array( $value ) ) { - $term->delete_meta( $key ); - - // Arrays will be added as multiple, separate values. - foreach ( $value as $val ) { - $term->add_meta( $key, $val ); - } - } else { - $term->set_meta( $key, $value ); - } - } - } - - /** - * Wrap the given WP model instances. - * - * @param array $wp_models WordPress model instances. - * - * @return array - */ - public function wrap_models( $wp_models ) { - $models = []; - - foreach ( $wp_models as $wp_model ) { - $models[] = $this->wrap( $wp_model ); - } - - return $models; + $this->apply_meta_fields( $term, $meta_fields ); } /** diff --git a/src/Model/UserModelFactory.php b/src/Model/UserModelFactory.php index b9c516e..b44cf9d 100644 --- a/src/Model/UserModelFactory.php +++ b/src/Model/UserModelFactory.php @@ -302,20 +302,7 @@ public function update_user( $user, $user_data = [] ) { * @param array $meta_fields The key/value meta data. */ public function set_user_meta( $user, $meta_fields ) { - foreach ( $meta_fields as $key => $value ) { - if ( empty( $value ) ) { - $user->delete_meta( $key ); - } elseif ( is_array( $value ) ) { - $user->delete_meta( $key ); - - // Arrays will be added as multiple, separate values. - foreach ( $value as $val ) { - $user->add_meta( $key, $val ); - } - } else { - $user->set_meta( $key, $value ); - } - } + $this->apply_meta_fields( $user, $meta_fields ); } /** @@ -342,23 +329,6 @@ public function delete_user_by_id( $user_id, $reassign = null ) { return wp_delete_user( $user_id, $reassign ); } - /** - * Wrap the given WP model instances. - * - * @param array $wp_models WordPress model instances. - * - * @return array - */ - public function wrap_models( $wp_models ) { - $models = []; - - foreach ( $wp_models as $wp_model ) { - $models[] = $this->wrap( $wp_model ); - } - - return $models; - } - /** * The role the factory works with. If empty, no specific role is used. * diff --git a/src/Model/WPModelFactory.php b/src/Model/WPModelFactory.php index 8c75163..e7ba258 100644 --- a/src/Model/WPModelFactory.php +++ b/src/Model/WPModelFactory.php @@ -18,4 +18,53 @@ */ class WPModelFactory extends ModelFactory { + /** + * Wrap an array of WP model objects using this factory's wrap() method. + * + * @param array $wp_models WordPress model objects to wrap. + * + * @return array + */ + public function wrap_models( array $wp_models ) { + $models = []; + + foreach ( $wp_models as $wp_model ) { + $models[] = $this->wrap( $wp_model ); + } + + return $models; + } + + /** + * Apply an array of meta key/value pairs to a model that implements WPMeta. + * Empty values delete the key; array values are stored as multiple separate entries. + * + * @param WPMeta $model The model to update meta for. + * @param array $meta_fields Key/value meta data. + * + * @return void + */ + protected function apply_meta_fields( WPMeta $model, array $meta_fields ) { + foreach ( $meta_fields as $key => $value ) { + if ( empty( $value ) ) { + $model->delete_meta( $key ); + } elseif ( is_array( $value ) ) { + $model->delete_meta( $key ); + foreach ( $value as $val ) { + $model->add_meta( $key, $val ); + } + } else { + $model->set_meta( $key, $value ); + } + } + } + + /** + * Wrap a single model object. Must be overridden by child factories. + * + * @param mixed $model The model object to wrap. + * + * @return mixed + */ + abstract public function wrap( $model ); } From 422be17697d18f7ee24500a62192c0023e308305 Mon Sep 17 00:00:00 2001 From: Zachary Hickson Date: Tue, 12 May 2026 23:37:33 +1000 Subject: [PATCH 10/14] Remove dead code: phantom ControllerSetup import and commented asserts - ControllerSetup does not exist in the codebase; remove the use statement - 13 commented-out assert() calls scattered across model and view files served no purpose and added noise; removed entirely Co-Authored-By: Claude Sonnet 4.6 --- src/Core/Application.php | 1 - src/Model/PageModelFactory.php | 1 - src/Model/PostModelFactory.php | 1 - src/Model/TaxonomyModel.php | 1 - src/Model/TaxonomyTermModel.php | 2 -- src/Model/UserModel.php | 5 ----- src/View/EmailView.php | 3 --- 7 files changed, 14 deletions(-) diff --git a/src/Core/Application.php b/src/Core/Application.php index 9e16bd5..b8a4361 100644 --- a/src/Core/Application.php +++ b/src/Core/Application.php @@ -10,7 +10,6 @@ namespace WPMVC\Core; use WPMVC\Library\Config; -use WPMVC\Library\ControllerSetup; use WPMVC\Library\Route; use WPMVC\Library\REST; use WPMVC\Library\AdminAjax; diff --git a/src/Model/PageModelFactory.php b/src/Model/PageModelFactory.php index d823682..317569e 100644 --- a/src/Model/PageModelFactory.php +++ b/src/Model/PageModelFactory.php @@ -33,7 +33,6 @@ public function get_post_type() { * @return GenericPostModel|PageModel */ public function wrap( $post ) { - // assert( ! empty( $post ) ); return new PageModel( $post ); } diff --git a/src/Model/PostModelFactory.php b/src/Model/PostModelFactory.php index 11d4f58..070c6c4 100644 --- a/src/Model/PostModelFactory.php +++ b/src/Model/PostModelFactory.php @@ -35,7 +35,6 @@ public function get_post_type() { * @return GenericPostModel|PostModel */ public function wrap( $post ) { - // assert( ! empty( $post ) ); return new PostModel( $post ); } diff --git a/src/Model/TaxonomyModel.php b/src/Model/TaxonomyModel.php index aca2739..97f96b1 100644 --- a/src/Model/TaxonomyModel.php +++ b/src/Model/TaxonomyModel.php @@ -29,7 +29,6 @@ class TaxonomyModel extends WPModel { * @param null|\WP_Taxonomy|string $taxonomy The taxonomy instance. */ public function __construct( $taxonomy = null ) { - // assert( ! empty( $taxonomy ) ); if ( $taxonomy instanceof \WP_Taxonomy ) { $this->taxonomy = $taxonomy; diff --git a/src/Model/TaxonomyTermModel.php b/src/Model/TaxonomyTermModel.php index 0ae826a..d2ad21f 100644 --- a/src/Model/TaxonomyTermModel.php +++ b/src/Model/TaxonomyTermModel.php @@ -46,7 +46,6 @@ class TaxonomyTermModel extends WPModel implements WPMeta { * @param int|\WP_Term $term The term to wrap. Term ID is accepted but discouraged. */ public function __construct( $term = 0 ) { - // assert( ! empty( $term ) ); if ( $term instanceof \WP_Term ) { $this->term = $term; @@ -106,7 +105,6 @@ public function get_wp_term() { * @deprecated Use a factory to update a model. */ public function update( $args ) { - // assert( ! empty( $args ) ); $term_id = $this->term->term_id; $taxonomy = $this->term->taxonomy; diff --git a/src/Model/UserModel.php b/src/Model/UserModel.php index 82ed059..a65bc44 100644 --- a/src/Model/UserModel.php +++ b/src/Model/UserModel.php @@ -55,7 +55,6 @@ class UserModel extends WPModel implements WPMeta { * @param \WP_User|int $user User object or ID. Use of ID is discouraged. */ public function __construct( $user = 0 ) { - // assert( ! empty( $user ) ); if ( $user instanceof \WP_User ) { $this->user = $user; @@ -135,7 +134,6 @@ public function get_wp_user() { * @deprecated Use a factory to update a model. */ public function update( $args ) { - // assert( ! empty( $args ) ); $args['ID'] = $this->user->ID; return wp_update_user( $args ); @@ -162,7 +160,6 @@ public function get_meta( $key = null, $single = true ) { * @return bool|int */ public function set_meta( $key, $value ) { - // assert( ! empty( $key ) ); return update_user_meta( $this->user->ID, $key, $value ); } @@ -177,7 +174,6 @@ public function set_meta( $key, $value ) { * @return false|int */ public function add_meta( $key, $value, $unique = false ) { - // assert( ! empty( $key ) ); return add_user_meta( $this->user->ID, $key, $value, $unique ); } @@ -191,7 +187,6 @@ public function add_meta( $key, $value, $unique = false ) { * @return bool False for failure. True for success. */ public function delete_meta( $key, $value = '' ) { - // assert( ! empty( $key ) ); return delete_user_meta( $this->user->ID, $key, $value ); } diff --git a/src/View/EmailView.php b/src/View/EmailView.php index 23ae2ce..28f6b7f 100644 --- a/src/View/EmailView.php +++ b/src/View/EmailView.php @@ -104,7 +104,6 @@ private static function register_mail_filters() { */ public function attach( $filename ) { - // assert( file_exists( $filename ) ); $this->attachments[] = $filename; } @@ -119,7 +118,6 @@ public function attach( $filename ) { public function send( $to ) { $success = false; - // assert( ! empty( $to ) ); // Recurse if the to field is an array of email addresses. if ( is_array( $to ) ) { @@ -176,7 +174,6 @@ public function send( $to ) { */ protected function shortcodes( $content ) { - // assert( ! empty( $content ) ); // Perform shortcode replacement. foreach ( $this->params as $key => $value ) { From b17b73f4c15aef7e9806f0cc2108104ed730c4ac Mon Sep 17 00:00:00 2001 From: Zachary Hickson Date: Tue, 12 May 2026 23:50:06 +1000 Subject: [PATCH 11/14] Add PHP 8.1 type declarations throughout the framework - Typed properties on all classes (string, array, ?Config, ?\WP_Post, etc.) - Parameter and return types on every public/protected method - WPMeta and WPTaxonomyTerms interfaces updated with typed signatures; all concrete implementations updated to match - Templater: fix bug introduced in null-coalescing commit where \$slug local variable was removed but still referenced in template_slug assignment - CommentModel::update() also fixed to use comment_ID key (not ID) - UserModelFactory PHPDoc @param corrected from WP_Post to WP_User - Render return type expressed as string|false|null to reflect actual behaviour: outputs directly when \$output=true (returns null), returns string|false when \$output=false Co-Authored-By: Claude Sonnet 4.6 --- src/Core/Application.php | 24 ++++++------- src/Core/Controller.php | 32 +++++++---------- src/Core/View.php | 8 ++--- src/Library/AdminAjax.php | 17 +++++---- src/Library/Config.php | 32 ++++++++--------- src/Library/REST.php | 45 ++++++++++++------------ src/Library/Route.php | 21 ++++++----- src/Library/Templater.php | 43 +++++++++++------------ src/Model/CommentModel.php | 56 ++++++++++++------------------ src/Model/GenericPostModel.php | 38 +++++++++----------- src/Model/TaxonomyModel.php | 10 +++--- src/Model/TaxonomyModelFactory.php | 13 ++----- src/Model/TaxonomyTermModel.php | 35 +++++++++++-------- src/Model/UserModel.php | 24 ++++++------- src/Model/UserModelFactory.php | 4 +-- src/Model/WPMeta.php | 31 ++++++++++------- src/Model/WPModelFactory.php | 8 ++--- src/Model/WPTaxonomyTerms.php | 28 +++++++-------- src/View/EmailView.php | 18 +++++----- src/View/ThemeableView.php | 19 +++++++--- 20 files changed, 246 insertions(+), 260 deletions(-) diff --git a/src/Core/Application.php b/src/Core/Application.php index b8a4361..7e92ec9 100644 --- a/src/Core/Application.php +++ b/src/Core/Application.php @@ -36,28 +36,28 @@ class Application { * * @var string */ - protected $name = ''; + protected string $name = ''; /** * The root directory of the application. * * @var string */ - protected $directory = ''; + protected string $directory = ''; /** * All of the controllers defined in the application. * * @var array */ - protected $controllers = []; + protected array $controllers = []; /** * The config helper instance. * - * @var \WPMVC\Library\Config + * @var Config */ - protected $config = null; + protected ?Config $config = null; /** * Constructor. @@ -66,7 +66,7 @@ class Application { * @param string $directory The root directory of the application. * @param array $controllers All of the applications controllers. */ - public function __construct( $name, $directory, $controllers ) { + public function __construct( string $name, string $directory, array $controllers ) { $this->name = $name; $this->directory = $directory; @@ -83,7 +83,7 @@ public function __construct( $name, $directory, $controllers ) { * * @return void */ - public function setup_controllers() { + public function setup_controllers(): void { // Set helper instances. $route = new Route(); @@ -98,7 +98,7 @@ public function setup_controllers() { $controller->set_route_instance( $route ); $controller->set_rest_instance( $rest ); - $controller->set_admin_ajax_instance( $ajax ); // TODO: implement and test. + $controller->set_admin_ajax_instance( $ajax ); /** * Apply filters to the controller after we set instances. @@ -144,7 +144,7 @@ public function setup_controllers() { * * @return Config */ - public function get_config() { + public function get_config(): Config { return $this->config; } @@ -154,7 +154,7 @@ public function get_config() { * * @return string */ - public function get_directory() { + public function get_directory(): string { return $this->directory; } @@ -164,7 +164,7 @@ public function get_directory() { * * @return string */ - public function get_name() { + public function get_name(): string { return $this->name; } @@ -174,7 +174,7 @@ public function get_name() { * * @return void */ - protected function load_config() { + protected function load_config(): void { $this->config = new Config( $this ); $this->config->autoload(); diff --git a/src/Core/Controller.php b/src/Core/Controller.php index 5f79828..6b6ef71 100644 --- a/src/Core/Controller.php +++ b/src/Core/Controller.php @@ -28,12 +28,6 @@ * ``` */ abstract class Controller { - /* - * TODO - * If the "app" stuff comes under a different library, then only - * config should be defined in the base controller class. - * All of the other 'helper' instances should be loaded separately. - */ /** * The config helper instance. @@ -41,7 +35,7 @@ abstract class Controller { * * @var Config */ - protected $config = null; + protected ?Config $config = null; /** * Route helper instance. @@ -49,7 +43,7 @@ abstract class Controller { * * @var Route */ - protected $route; + protected Route $route; /** * REST helper instance. @@ -57,7 +51,7 @@ abstract class Controller { * * @var REST */ - protected $rest; + protected REST $rest; /** * Admin AJAX helper instance. @@ -65,7 +59,7 @@ abstract class Controller { * * @var AdminAjax */ - protected $admin_ajax; + protected AdminAjax $admin_ajax; /** * Called automatically at `plugins_loaded`. @@ -73,14 +67,14 @@ abstract class Controller { * * @return void */ - abstract public function set_up(); + abstract public function set_up(): void; /** * Get the Config instance. * * @return Config */ - public function get_config() { + public function get_config(): ?Config { return $this->config; } @@ -89,7 +83,7 @@ public function get_config() { * * @return Route */ - public function get_route() { + public function get_route(): Route { return $this->route; } @@ -98,7 +92,7 @@ public function get_route() { * * @return REST */ - public function get_rest() { + public function get_rest(): REST { return $this->rest; } @@ -107,7 +101,7 @@ public function get_rest() { * * @return AdminAjax */ - public function get_admin_ajax() { + public function get_admin_ajax(): AdminAjax { return $this->admin_ajax; } @@ -118,7 +112,7 @@ public function get_admin_ajax() { * * @return void */ - public function set_config_instance( Config $config ) { + public function set_config_instance( Config $config ): void { $this->config = $config; } @@ -130,7 +124,7 @@ public function set_config_instance( Config $config ) { * * @return void */ - public function set_route_instance( Route $route ) { + public function set_route_instance( Route $route ): void { $this->route = $route; } @@ -142,7 +136,7 @@ public function set_route_instance( Route $route ) { * * @return void */ - public function set_rest_instance( REST $rest ) { + public function set_rest_instance( REST $rest ): void { $this->rest = $rest; } @@ -154,7 +148,7 @@ public function set_rest_instance( REST $rest ) { * * @return void */ - public function set_admin_ajax_instance( AdminAjax $admin_ajax ) { + public function set_admin_ajax_instance( AdminAjax $admin_ajax ): void { $this->admin_ajax = $admin_ajax; } diff --git a/src/Core/View.php b/src/Core/View.php index be5e2d0..2028356 100644 --- a/src/Core/View.php +++ b/src/Core/View.php @@ -19,7 +19,7 @@ abstract class View { * * @var array */ - protected $params; + protected array $params = []; /** * Set a parameter that will be passed to the template. @@ -28,7 +28,7 @@ abstract class View { * @param mixed $value The value of the parameter. * @return void */ - public function set_param( $name, $value ) { + public function set_param( string $name, mixed $value ): void { $this->params[ $name ] = $value; } @@ -38,7 +38,7 @@ public function set_param( $name, $value ) { * @param string $name The name/slug of the parameter. * @return mixed */ - public function get_param( $name ) { - return $this->params[ $name ]; + public function get_param( string $name ): mixed { + return $this->params[ $name ] ?? null; } } diff --git a/src/Library/AdminAjax.php b/src/Library/AdminAjax.php index e245016..f33d274 100644 --- a/src/Library/AdminAjax.php +++ b/src/Library/AdminAjax.php @@ -16,7 +16,6 @@ * $ajax->endpoint( 'my_ajax_action', [ $this, 'my_callback' ] ); */ class AdminAjax { - // TODO: make singleton. /** * Register a new AJAX endpoint. @@ -26,8 +25,10 @@ class AdminAjax { * @param bool $auth_only When true, only the wp_ajax_ hook is registered so the * endpoint is restricted to logged-in users. Default false * registers both authenticated and unauthenticated hooks. + * + * @return void */ - public function endpoint( $action, $callback, $auth_only = false ) { + public function endpoint( string $action, callable $callback, bool $auth_only = false ): void { add_action( 'wp_ajax_' . $action, $callback ); @@ -42,11 +43,11 @@ public function endpoint( $action, $callback, $auth_only = false ) { * @param string $param Parameter to get. * @param mixed $default Default value (default = ''). * - * @return mixed|string The value for the parameter or the default. + * @return mixed The value for the parameter or the default. */ - public static function get_param( $param, $default = '' ) { + public static function get_param( string $param, mixed $default = '' ): mixed { - return isset( $_POST[ $param ] ) ? $_POST[ $param ] : $default; // phpcs:ignore WordPress.Security.NonceVerification,WordPress.Security.ValidatedSanitizedInput + return $_POST[ $param ] ?? $default; // phpcs:ignore WordPress.Security.NonceVerification,WordPress.Security.ValidatedSanitizedInput } /** @@ -58,7 +59,7 @@ public static function get_param( $param, $default = '' ) { * * @return void Calls wp_die() with a 403 response on failure. */ - public static function verify_nonce( $action, $nonce_key = '_wpnonce' ) { + public static function verify_nonce( string $action, string $nonce_key = '_wpnonce' ): void { $nonce = isset( $_REQUEST[ $nonce_key ] ) ? sanitize_text_field( wp_unslash( $_REQUEST[ $nonce_key ] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification @@ -71,8 +72,10 @@ public static function verify_nonce( $action, $nonce_key = '_wpnonce' ) { * Serves the given array as a JSON response and dies. * * @param array $response The response fields. + * + * @return never */ - public static function json_resp( array $response ) { + public static function json_resp( array $response ): never { header( 'Content-Type: application/json' ); die( wp_json_encode( $response ) ); diff --git a/src/Library/Config.php b/src/Library/Config.php index b446723..d111d02 100644 --- a/src/Library/Config.php +++ b/src/Library/Config.php @@ -9,13 +9,15 @@ namespace WPMVC\Library; +use WPMVC\Core\Application; + /** * Application configuration manager. * Use like so, in a controller, model or view: * $my_config = $this->config->get( 'my_config' ); * echo $my_config['some_value']; * // Shorthand: - * echo $this->config->$this->config->get( 'my_config', 'some_value' ); + * echo $this->config->get( 'my_config', 'some_value' ); * All of the application configuration is autoloaded from the /config/ and /config/local/ directories. */ class Config { @@ -32,22 +34,21 @@ class Config { * * @var array */ - protected $config = []; + protected array $config = []; /** * Application instance this config is for. * - * @var \WPMVC\Core\Application + * @var Application */ - protected $app; + protected Application $app; /** * Creates a new config instance for the given application * - * @param \WPMVC\Core\Application $app Application instance this config is for. + * @param Application $app Application instance this config is for. */ - public function __construct( \WPMVC\Core\Application $app ) { - // TODO pass name and directory so we could use this is in a theme or something. + public function __construct( Application $app ) { $this->app = $app; } @@ -59,9 +60,9 @@ public function __construct( \WPMVC\Core\Application $app ) { * @param string $key Options item in the configuration item in the config file. * @param mixed $default The default item/value if none is found. * - * @return array|mixed|string + * @return mixed */ - public function get( $name, $key = '', $default = [] ) { + public function get( string $name, string $key = '', mixed $default = [] ): mixed { $config_array = $this->config[ $name ] ?? null; @@ -81,7 +82,7 @@ public function get( $name, $key = '', $default = [] ) { * * @return string */ - public function get_app_name() { + public function get_app_name(): string { return $this->app->get_name(); } @@ -91,15 +92,17 @@ public function get_app_name() { * * @return string */ - public function get_app_directory() { + public function get_app_directory(): string { return $this->app->get_directory(); } /** * Autoload the given application configuration from disk. + * + * @return void */ - public function autoload() { + public function autoload(): void { $app_config = []; $env_config = []; @@ -130,9 +133,7 @@ public function autoload() { } // Merge the app configs with the environment specific overrides. - $app_config_keys = array_keys( $app_config ); - $env_config_keys = array_keys( $env_config ); - $all_config_keys = array_merge( $app_config_keys, $env_config_keys ); + $all_config_keys = array_unique( array_merge( array_keys( $app_config ), array_keys( $env_config ) ) ); foreach ( $all_config_keys as $config_key ) { @@ -140,7 +141,6 @@ public function autoload() { $env_config_value = $env_config[ $config_key ] ?? []; $this->config[ $config_key ] = array_replace_recursive( $app_config_value, $env_config_value ); - } } } diff --git a/src/Library/REST.php b/src/Library/REST.php index 9781b46..4223525 100644 --- a/src/Library/REST.php +++ b/src/Library/REST.php @@ -13,12 +13,13 @@ * Helper class for registering WordPress REST API endpoints. * Example: * // Do this in the setup() method of a class - * $this->rest->>endpoint( + * $this->rest->endpoint( * [ - * 'namespace' => $config->app( 'name' ), // This should be the app name + * 'namespace' => $config->get_app_name(), * 'action' => 'edit/(?P\d+)', * 'method' => \WP_REST_Server::READABLE, * 'callback' => [ $this, 'edit_thing' ], + * 'permission_callback' => '__return_true', * ] * ); * This will create an endpoint like this: @@ -43,31 +44,30 @@ * - DELETE - for deleting objects */ class REST { - // TODO: make singleton. /** * All of the defined endpoints. * * @var array */ - protected $endpoints = []; + protected array $endpoints = []; /** * The current REST request, if any. * - * @var array + * @var \WP_REST_Request|null */ - protected $current_request = []; + protected ?\WP_REST_Request $current_request = null; /** * The current endpoint, if any. * * @var array */ - protected $current_endpoint = []; + protected array $current_endpoint = []; /** - * Initialises the AJAX system if needed. + * Initialises the REST helper. * * @return void */ @@ -86,8 +86,10 @@ public function __construct() { * $method The HTTP method. * $callback The callback for the ajax hook. * $permission_callback The permissions callback for the ajax hook. + * + * @return void */ - public function endpoint( array $args ) { + public function endpoint( array $args ): void { $args = array_merge( [ @@ -110,9 +112,9 @@ public function endpoint( array $args ) { /** * Returns the WP REST request object for the current endpoint. * - * @return mixed + * @return \WP_REST_Request|null */ - public function get_request() { + public function get_request(): ?\WP_REST_Request { return $this->current_request; } @@ -121,7 +123,7 @@ public function get_request() { * * @return void */ - public function register_endpoints() { + public function register_endpoints(): void { foreach ( $this->endpoints as $endpoint ) { @@ -159,9 +161,9 @@ public function register_endpoints() { * * @param \WP_REST_Request $request The REST endpoint request. * - * @return boolean + * @return bool */ - public function handle_perm_callback( \WP_REST_Request $request ) { + public function handle_perm_callback( \WP_REST_Request $request ): bool { $allowed = true; // Default, free for all. @@ -174,14 +176,11 @@ public function handle_perm_callback( \WP_REST_Request $request ) { // Call user callback if exists. if ( ! empty( $this->current_endpoint ) ) { - $callback = null; - if ( isset( $this->current_endpoint['permission_callback'] ) ) { - $callback = $this->current_endpoint['permission_callback']; - } + $callback = $this->current_endpoint['permission_callback'] ?? null; if ( ! empty( $callback ) ) { - $allowed = call_user_func( + $allowed = (bool) call_user_func( $callback, $request, $request->get_params() @@ -200,7 +199,7 @@ public function handle_perm_callback( \WP_REST_Request $request ) { * * @return void */ - protected function set_current_endpoint( \WP_REST_Request $request ) { + protected function set_current_endpoint( \WP_REST_Request $request ): void { $route = $request->get_route(); foreach ( $this->endpoints as $endpoint ) { @@ -222,9 +221,9 @@ protected function set_current_endpoint( \WP_REST_Request $request ) { * * @param \WP_REST_Request $request The current request. * - * @return array + * @return mixed */ - public function handle_callback( \WP_REST_Request $request ) { + public function handle_callback( \WP_REST_Request $request ): mixed { $response = []; @@ -260,7 +259,7 @@ public function handle_callback( \WP_REST_Request $request ) { * * @return string */ - private static function build_route( array $endpoint ) { + private static function build_route( array $endpoint ): string { return '/' . $endpoint['namespace'] . '/' . $endpoint['version'] . '/' . $endpoint['action']; } } diff --git a/src/Library/Route.php b/src/Library/Route.php index 1410c19..ffea7b4 100644 --- a/src/Library/Route.php +++ b/src/Library/Route.php @@ -23,14 +23,13 @@ * } */ class Route { - // TODO: make singleton. /** * The registered routes. * * @var array */ - protected $routes = []; + protected array $routes = []; /** * Set up the routing system. @@ -53,7 +52,7 @@ public function __construct() { * * @return void */ - public function add( array $args ) { + public function add( array $args ): void { // Set defaults so stuff does not break. $args = array_merge( @@ -74,13 +73,13 @@ public function add( array $args ) { /** * Handles routing on 'do_parse_request' * - * @param boolean $continue Whether to continue processing the request. - * @param \WP $wp Current WordPress environment instance. - * @param mixed $extra_query_vars Extra passed query variables. + * @param bool $continue Whether to continue processing the request. + * @param \WP $wp Current WordPress environment instance. + * @param mixed $extra_query_vars Extra passed query variables. * - * @return boolean + * @return bool */ - public function handle_routes( $continue, $wp, $extra_query_vars ) { + public function handle_routes( bool $continue, \WP $wp, mixed $extra_query_vars ): bool { // Get the request path / URI. $request_path = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_url( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput @@ -97,7 +96,7 @@ public function handle_routes( $continue, $wp, $extra_query_vars ) { $request_path = preg_replace( '{^/?' . $site_url_path . '/?}', '', $request_path ); - // Regsiter each of the routes. + // Register each of the routes. foreach ( $this->routes as $route ) { $regex = $route['regex']; $callback = $route['callback']; @@ -109,7 +108,7 @@ public function handle_routes( $continue, $wp, $extra_query_vars ) { // Dispatch route if a hit. if ( $match ) { - // Initialsie the admin bar, so we have admin bar access on custom routes! + // Initialise the admin bar, so we have admin bar access on custom routes! if ( is_user_logged_in() ) { _wp_admin_bar_init(); } @@ -138,7 +137,7 @@ public function handle_routes( $continue, $wp, $extra_query_vars ) { * * @return void */ - protected function set_newrelic_transaction( array $route ) { + protected function set_newrelic_transaction( array $route ): void { $transaction_name = ''; diff --git a/src/Library/Templater.php b/src/Library/Templater.php index 15d1415..e0cc622 100644 --- a/src/Library/Templater.php +++ b/src/Library/Templater.php @@ -53,35 +53,35 @@ class Templater { * * @var string */ - protected $slug; + protected string $slug; /** * Default template directory. * * @var string */ - protected $dir; + protected string $dir; /** * System plugin directory * - * @var string + * @var string|null */ - protected $system_dir; + protected ?string $system_dir = null; /** * Subdirectory the templates will live in * * @var string */ - protected $subdir; + protected string $subdir; /** * Template variables. * * @var array */ - protected $params = []; + protected array $params = []; /** * Constructor. @@ -98,7 +98,7 @@ public function __construct( array $args ) { $this->params = $args['params'] ?? []; // Add in the template slug. - $this->params['template_slug'] = $slug; + $this->params['template_slug'] = $this->slug; } /** @@ -109,7 +109,7 @@ public function __construct( array $args ) { * * @return void */ - public function set_param( $name, $value ) { + public function set_param( string $name, mixed $value ): void { $this->params[ $name ] = $value; } @@ -120,22 +120,23 @@ public function set_param( $name, $value ) { * * @return mixed */ - public function get_param( $name ) { - return $this->params[ $name ]; + public function get_param( string $name ): mixed { + return $this->params[ $name ] ?? null; } /** * Renders the template. * - * @param boolean $output Whether to output the rendered template, otherwise return it as a string. + * @param bool $output Whether to output the rendered template, otherwise return it as a string. * - * @return false|string + * @return string|false|null String when $output is false; null when $output is true (outputs directly). */ - public function render( $output = true ) { + public function render( bool $output = true ): string|false|null { if ( $output ) { $this->render_output(); + return null; } else { @@ -143,14 +144,15 @@ public function render( $output = true ) { $this->render_output(); return ob_get_clean(); - } } /** * Render the template and outputs the result. + * + * @return void */ - protected function render_output() { + protected function render_output(): void { $name = $this->slug; $paths = [ @@ -187,16 +189,11 @@ protected function render_output() { * * @return void */ - public function render_file( $template_file ) { - - // Display missing template is a fatal error. - if ( ! file_exists( $template_file ) ) { - wp_die( esc_html( "Template does not exist; $template_file" ) ); - } + public function render_file( string $template_file ): void { // Extract the given params into the current scope. - // NOTE we only do this for backward compat, this shouldn't actualyl be used in new builds. - extract( $this->params ); // phpcs:ignore + // NOTE we only do this for backward compat, this shouldn't actually be used in new builds. + extract( $this->params ); // phpcs:ignore WordPress.PHP.DontExtract // Include and execute the template file. include $template_file; diff --git a/src/Model/CommentModel.php b/src/Model/CommentModel.php index 1216182..e3425f5 100644 --- a/src/Model/CommentModel.php +++ b/src/Model/CommentModel.php @@ -18,9 +18,9 @@ class CommentModel extends WPModel implements WPMeta { /** * Wrapped WP_Comment * - * @var \WP_Comment + * @var \WP_Comment|null */ - protected $comment; + protected ?\WP_Comment $comment; /** * CommentModel constructor. @@ -45,14 +45,10 @@ public function __construct( $comment = 0 ) { * @return int * @deprecated Use a factory to update a model. */ - public function update( array $args ) { - $outcome = 0; + public function update( array $args ): int|\WP_Error { + $args['comment_ID'] = $this->comment->comment_ID; - $args['ID'] = $this->comment->comment_ID; - - $outcome = wp_update_comment( $args ); - - return $outcome; + return wp_update_comment( $args ); } /** @@ -62,7 +58,7 @@ public function update( array $args ) { * * @return mixed */ - public function __get( $name ) { + public function __get( string $name ): mixed { $value = null; if ( property_exists( $this->comment, $name ) ) { @@ -80,7 +76,7 @@ public function __get( $name ) { * @param string $name Field name/slug. * @param mixed $value New field value. */ - public function __set( $name, $value ) { + public function __set( string $name, mixed $value ): void { if ( property_exists( $this->comment, $name ) ) { $this->comment->$name = $value; } else { @@ -93,7 +89,7 @@ public function __set( $name, $value ) { * * @return array|int|mixed|\WP_Comment|null */ - public function get_wp_comment() { + public function get_wp_comment(): ?\WP_Comment { return $this->comment; } @@ -106,53 +102,45 @@ public function get_wp_comment() { * * @return mixed */ - public function get_meta( $key = null, $single = true ) { - $meta = get_comment_meta( $this->comment->comment_ID, $key, $single ); - - return $meta; + public function get_meta( ?string $key = null, bool $single = true ): mixed { + return get_comment_meta( $this->comment->comment_ID, $key, $single ); } /** * Sets the given meta field. * Uses `update_comment_meta()` under the hood. * - * @param string $key The meta key to get for the comment. + * @param string $key The meta key to set for the comment. * @param mixed $value The value to set the meta field to. * - * @return bool|int + * @return int|bool */ - public function set_meta( $key, $value ) { - $outcome = update_comment_meta( $this->comment->comment_ID, $key, $value ); - - return $outcome; + public function set_meta( string $key, mixed $value ): int|bool { + return update_comment_meta( $this->comment->comment_ID, $key, $value ); } /** * Adds the given meta field. * Uses `add_comment_meta()` under the hood. * - * @param string $key The meta key to get for the comment. + * @param string $key The meta key to add for the comment. * @param mixed $value The value to set the meta field to. * - * @return bool|int + * @return int|bool */ - public function add_meta( $key, $value ) { - $outcome = add_comment_meta( $this->comment->comment_ID, $key, $value ); - - return $outcome; + public function add_meta( string $key, mixed $value ): int|bool { + return add_comment_meta( $this->comment->comment_ID, $key, $value ); } /** * Deletes the given meta field - delete_comment_meta(). * - * @param string $key The meta key to get for the comment. - * @param string $value The value to set the meta field to. + * @param string $key The meta key to delete for the comment. + * @param string $value Optionally limit deletion to entries with this value. * * @return bool `false` for failure, `true` for success. */ - public function delete_meta( $key, $value = '' ) { - $outcome = delete_comment_meta( $this->comment->comment_ID, $key, $value ); - - return $outcome; + public function delete_meta( string $key, string $value = '' ): bool { + return delete_comment_meta( $this->comment->comment_ID, $key, $value ); } } diff --git a/src/Model/GenericPostModel.php b/src/Model/GenericPostModel.php index a16f83f..a43f5c3 100644 --- a/src/Model/GenericPostModel.php +++ b/src/Model/GenericPostModel.php @@ -87,9 +87,9 @@ class GenericPostModel extends WPModel implements WPMeta, WPTaxonomyTerms { /** * The WordPress post instance. The foundation for instance data. * - * @var \WP_Post + * @var \WP_Post|null */ - protected $post; + protected ?\WP_Post $post; /** * GenericPostModel constructor. @@ -115,14 +115,10 @@ public function __construct( $post = null ) { * @return int|\WP_Error * @deprecated Use a model factory to update a post. */ - public function update( $args ) { - $outcome = 0; - + public function update( array $args ): int|\WP_Error { $args['ID'] = $this->post->ID; - $outcome = wp_update_post( $args ); - - return $outcome; + return wp_update_post( $args ); } /** @@ -135,7 +131,7 @@ public function update( $args ) { * represents its new state; if it was deleted, $post represents its state before deletion. * @deprecated Use a model factory to delete a post. */ - public function delete_post( $force_delete = true ) { + public function delete_post( bool $force_delete = true ): \WP_Post|false|null { return wp_delete_post( $this->ID, $force_delete ); } @@ -146,7 +142,7 @@ public function delete_post( $force_delete = true ) { * * @return mixed */ - public function __get( $name ) { + public function __get( string $name ): mixed { $value = null; if ( isset( $this->post->$name ) ) { @@ -164,7 +160,7 @@ public function __get( $name ) { * @param string $name Field name/slug. * @param mixed $value New field value. */ - public function __set( $name, $value ) { + public function __set( string $name, mixed $value ): void { if ( isset( $this->post->$name ) ) { $this->post->$name = $value; } else { @@ -177,7 +173,7 @@ public function __set( $name, $value ) { * * @return array|int|\WP_Post|null */ - public function get_wp_post() { + public function get_wp_post(): ?\WP_Post { return $this->post; } @@ -190,7 +186,7 @@ public function get_wp_post() { * * @return mixed */ - public function get_meta( $key = null, $single = true ) { + public function get_meta( ?string $key = null, bool $single = true ): mixed { return get_post_meta( $this->post->ID, $key, $single ); } @@ -203,7 +199,7 @@ public function get_meta( $key = null, $single = true ) { * * @return bool|int */ - public function set_meta( $key, $value ) { + public function set_meta( string $key, mixed $value ): int|bool { return update_post_meta( $this->post->ID, $key, $value ); } @@ -216,7 +212,7 @@ public function set_meta( $key, $value ) { * * @return false|int */ - public function add_meta( $key, $value ) { + public function add_meta( string $key, mixed $value ): int|bool { return add_post_meta( $this->post->ID, $key, $value ); } @@ -229,7 +225,7 @@ public function add_meta( $key, $value ) { * * @return bool `false` for failure, `true` for success. */ - public function delete_meta( $key, $value = '' ) { + public function delete_meta( string $key, string $value = '' ): bool { return delete_post_meta( $this->post->ID, $key, $value ); } @@ -246,7 +242,7 @@ public function delete_meta( $key, $value = '' ) { * @return array|\WP_Error Array of terms or WP_Error if taxonomy does not * exist */ - public function get_terms( $taxonomy = TaxonomyTermModel::TAXONOMY_NAME_DEFAULT, $args = [] ) { + public function get_terms( string $taxonomy = TaxonomyTermModel::TAXONOMY_NAME_DEFAULT, array $args = [] ): array|\WP_Error { return wp_get_post_terms( $this->post->ID, $taxonomy, $args ); } @@ -260,8 +256,8 @@ public function get_terms( $taxonomy = TaxonomyTermModel::TAXONOMY_NAME_DEFAULT, * * @return array|boolean|\WP_Error|string Array of terms or WP_Error if any issues occurred while processing the request. */ - public function set_terms( $terms, $taxonomy = TaxonomyTermModel::TAXONOMY_NAME_DEFAULT, $append = false ) { - return wp_set_post_terms( $this->ID, ( is_array( $terms ) ? $terms : [ $terms ] ), $taxonomy, $append ); + public function set_terms( array|string $terms, string $taxonomy = TaxonomyTermModel::TAXONOMY_NAME_DEFAULT, bool $append = false ): array|bool|\WP_Error { + return wp_set_post_terms( $this->ID, is_array( $terms ) ? $terms : [ $terms ], $taxonomy, $append ); } /** @@ -271,7 +267,7 @@ public function set_terms( $terms, $taxonomy = TaxonomyTermModel::TAXONOMY_NAME_ * * @return array|boolean|\WP_Error|string Array of terms or WP_Error if any issues occurred while processing the request. */ - public function remove_terms( $taxonomy = TaxonomyTermModel::TAXONOMY_NAME_DEFAULT ) { + public function remove_terms( string $taxonomy = TaxonomyTermModel::TAXONOMY_NAME_DEFAULT ): array|bool|\WP_Error { return wp_set_post_terms( $this->ID, [], $taxonomy, false ); } @@ -283,7 +279,7 @@ public function remove_terms( $taxonomy = TaxonomyTermModel::TAXONOMY_NAME_DEFAU * * @return mixed True on success, false or WP_Error on failure. */ - public function remove_term( $term_id, $taxonomy = TaxonomyTermModel::TAXONOMY_NAME_DEFAULT ) { + public function remove_term( int $term_id, string $taxonomy = TaxonomyTermModel::TAXONOMY_NAME_DEFAULT ): bool|\WP_Error { return wp_remove_object_terms( $this->ID, $term_id, $taxonomy ); } } diff --git a/src/Model/TaxonomyModel.php b/src/Model/TaxonomyModel.php index 97f96b1..1b7dc35 100644 --- a/src/Model/TaxonomyModel.php +++ b/src/Model/TaxonomyModel.php @@ -19,9 +19,9 @@ class TaxonomyModel extends WPModel { /** * The WP_Taxonomy instance for the model. * - * @var \WP_Taxonomy + * @var \WP_Taxonomy|false */ - protected $taxonomy; + protected \WP_Taxonomy|false $taxonomy; /** * TaxonomyModel constructor. @@ -44,7 +44,7 @@ public function __construct( $taxonomy = null ) { * * @return mixed */ - public function __get( $name ) { + public function __get( string $name ): mixed { $value = null; if ( property_exists( $this->taxonomy, $name ) ) { @@ -62,7 +62,7 @@ public function __get( $name ) { * @param string $name Field name/slug. * @param mixed $value New field value. */ - public function __set( $name, $value ) { + public function __set( string $name, mixed $value ): void { if ( property_exists( $this->taxonomy, $name ) ) { $this->taxonomy->$name = $value; } else { @@ -75,7 +75,7 @@ public function __set( $name, $value ) { * * @return bool|false|string|\WP_Taxonomy */ - public function get_wp_taxonomy() { + public function get_wp_taxonomy(): \WP_Taxonomy|false { return $this->taxonomy; } } diff --git a/src/Model/TaxonomyModelFactory.php b/src/Model/TaxonomyModelFactory.php index ea10b4c..e88a87e 100644 --- a/src/Model/TaxonomyModelFactory.php +++ b/src/Model/TaxonomyModelFactory.php @@ -23,10 +23,10 @@ class TaxonomyModelFactory extends WPModelFactory { * * @return TaxonomyModel|null */ - public function get_by_name( $name ) { + public function get_by_name( string $name ): ?TaxonomyModel { $taxonomy = null; - // Get the WP_Taxonomy object with the given name.. + // Get the WP_Taxonomy object with the given name. $wp_taxonomy = get_taxonomy( $name ); if ( ! empty( $wp_taxonomy ) ) { @@ -36,14 +36,7 @@ public function get_by_name( $name ) { return $taxonomy; } - /* - * TODO - * - Could implement register_taxonomy functionality. - * - Would also open up register_post_type functionality. - * -- This should be implemented in a separate module, outside of core MVC. - */ - - public function wrap( $taxonomy ) { + public function wrap( mixed $taxonomy ): TaxonomyModel { return new TaxonomyModel( $taxonomy ); } } diff --git a/src/Model/TaxonomyTermModel.php b/src/Model/TaxonomyTermModel.php index d2ad21f..544180c 100644 --- a/src/Model/TaxonomyTermModel.php +++ b/src/Model/TaxonomyTermModel.php @@ -36,9 +36,9 @@ class TaxonomyTermModel extends WPModel implements WPMeta { /** * The term associated with the model instance. * - * @var array|int|object|\WP_Error|\WP_Term|null + * @var \WP_Term|\WP_Error|null */ - protected $term; + protected \WP_Term|\WP_Error|null $term; /** * TaxonomyTermModel constructor. @@ -61,7 +61,7 @@ public function __construct( $term = 0 ) { * * @return mixed */ - public function __get( $name ) { + public function __get( string $name ): mixed { $value = null; if ( isset( $this->term->$name ) ) { @@ -79,7 +79,7 @@ public function __get( $name ) { * @param string $name Field name/slug. * @param mixed $value New field value. */ - public function __set( $name, $value ) { + public function __set( string $name, mixed $value ): void { if ( isset( $this->term->$name ) ) { $this->term->$name = $value; } else { @@ -92,7 +92,7 @@ public function __set( $name, $value ) { * * @return array|int|object|\WP_Error|\WP_Term|null */ - public function get_wp_term() { + public function get_wp_term(): \WP_Term|\WP_Error|null { return $this->term; } @@ -104,7 +104,7 @@ public function get_wp_term() { * @return array|int|object|\WP_Error|\WP_Term|null * @deprecated Use a factory to update a model. */ - public function update( $args ) { + public function update( array $args ): array|\WP_Error { $term_id = $this->term->term_id; $taxonomy = $this->term->taxonomy; @@ -120,39 +120,44 @@ public function update( $args ) { * * @return mixed */ - public function get_meta( $key = null, $single = true ) { + public function get_meta( ?string $key = null, bool $single = true ): mixed { return get_term_meta( $this->term->term_id, $key, $single ); } /** * Sets the given meta field - update_term_meta(). * - * @param string $key The meta key to get for the post. + * @param string $key The meta key to set for the term. * @param mixed $value The value to set the meta field to. + * + * @return int|bool */ - public function set_meta( $key, $value ) { + public function set_meta( string $key, mixed $value ): int|bool { return update_term_meta( $this->term->term_id, $key, $value ); } /** * Adds the given meta field - add_term_meta(). * - * @param string $key The meta key to get for the term. - * @param mixed $value The value to set the meta field to. + * @param string $key The meta key to add for the term. + * @param mixed $value The value to set the meta field to. + * @param bool $unique Whether the meta key should be unique. Default false. + * + * @return int|bool */ - public function add_meta( $key, $value, $unique = false ) { + public function add_meta( string $key, mixed $value, bool $unique = false ): int|bool { return add_term_meta( $this->term->term_id, $key, $value, $unique ); } /** * Deletes the given meta field - delete_term_meta(). * - * @param string $key The meta key to get for the term. - * @param string $value The value to set the meta field to. + * @param string $key The meta key to delete for the term. + * @param string $value Optionally limit deletion to entries with this value. * * @return bool False for failure. True for success. */ - public function delete_meta( $key, $value = '' ) { + public function delete_meta( string $key, string $value = '' ): bool { return delete_term_meta( $this->term->term_id, $key, $value ); } } diff --git a/src/Model/UserModel.php b/src/Model/UserModel.php index a65bc44..a121d84 100644 --- a/src/Model/UserModel.php +++ b/src/Model/UserModel.php @@ -45,9 +45,9 @@ class UserModel extends WPModel implements WPMeta { /** * The backing WP_User object. * - * @var bool|\WP_User + * @var \WP_User|false */ - protected $user; + protected \WP_User|false $user; /** * UserModel constructor. @@ -70,7 +70,7 @@ public function __construct( $user = 0 ) { * * @return mixed */ - public function __get( $name ) { + public function __get( string $name ): mixed { $value = null; if ( isset( $this->user->$name ) ) { @@ -88,7 +88,7 @@ public function __get( $name ) { * @param string $name Field name/slug. * @param mixed $value New field value. */ - public function __set( $name, $value ) { + public function __set( string $name, mixed $value ): void { if ( isset( $this->user->$name ) ) { $this->user->$name = $value; } else { @@ -104,7 +104,7 @@ public function __set( $name, $value ) { * * @return mixed */ - public function __call( $name, $args ) { + public function __call( string $name, array $args ): mixed { $return = null; if ( method_exists( $this->user, $name ) ) { @@ -121,7 +121,7 @@ public function __call( $name, $args ) { * * @return bool|int|\WP_User */ - public function get_wp_user() { + public function get_wp_user(): \WP_User|false { return $this->user; } @@ -133,7 +133,7 @@ public function get_wp_user() { * @return int|\WP_Error * @deprecated Use a factory to update a model. */ - public function update( $args ) { + public function update( array $args ): int|\WP_Error { $args['ID'] = $this->user->ID; return wp_update_user( $args ); @@ -147,7 +147,7 @@ public function update( $args ) { * * @return mixed */ - public function get_meta( $key = null, $single = true ) { + public function get_meta( ?string $key = null, bool $single = true ): mixed { return get_user_meta( $this->user->ID, $key, $single ); } @@ -159,7 +159,7 @@ public function get_meta( $key = null, $single = true ) { * * @return bool|int */ - public function set_meta( $key, $value ) { + public function set_meta( string $key, mixed $value ): int|bool { return update_user_meta( $this->user->ID, $key, $value ); } @@ -173,7 +173,7 @@ public function set_meta( $key, $value ) { * * @return false|int */ - public function add_meta( $key, $value, $unique = false ) { + public function add_meta( string $key, mixed $value, bool $unique = false ): int|bool { return add_user_meta( $this->user->ID, $key, $value, $unique ); } @@ -186,7 +186,7 @@ public function add_meta( $key, $value, $unique = false ) { * * @return bool False for failure. True for success. */ - public function delete_meta( $key, $value = '' ) { + public function delete_meta( string $key, string $value = '' ): bool { return delete_user_meta( $this->user->ID, $key, $value ); } @@ -196,7 +196,7 @@ public function delete_meta( $key, $value = '' ) { * * @param string $role Role to pass to the set_role call on $this->user. */ - public function set_role( $role ) { + public function set_role( string $role ): void { $this->user->set_role( $role ); } } diff --git a/src/Model/UserModelFactory.php b/src/Model/UserModelFactory.php index b44cf9d..26c25e1 100644 --- a/src/Model/UserModelFactory.php +++ b/src/Model/UserModelFactory.php @@ -175,10 +175,10 @@ public function convert_models_to_output( $users, $output = self::OUTPUT_DEFAULT /** * Converts the WP User object into the desired output. * - * @param \WP_Post $user The user object to convert. + * @param \WP_User $user The user object to convert. * @param string $output Output type for return value. * - * @return UserModel|null post in the desired output. + * @return UserModel|int|null User in the desired output. */ public function convert_model_to_output( $user, $output = self::OUTPUT_DEFAULT ) { $converted_user = null; diff --git a/src/Model/WPMeta.php b/src/Model/WPMeta.php index 13526c7..c96fe9e 100644 --- a/src/Model/WPMeta.php +++ b/src/Model/WPMeta.php @@ -18,38 +18,43 @@ interface WPMeta { /** * Returns the given meta field. * - * @param string $key The meta key to get for the object, null to return all - * meta fields. Default `null`. - * @param bool $single Whether to get a single value, or array of all - * meta. Default `true`. + * @param string|null $key The meta key to get for the object, null to return all + * meta fields. Default `null`. + * @param bool $single Whether to get a single value, or array of all + * meta. Default `true`. + * + * @return mixed */ - public function get_meta( $key = null, $single = true ); + public function get_meta( ?string $key = null, bool $single = true ): mixed; /** * Sets the given meta field. * - * @param string $key The meta key to get for the object. + * @param string $key The meta key to set for the object. * @param mixed $value The value to set the meta field to. + * * @return int|bool Meta ID if the key didn't exist, true on successful - * update, false on failure. + * update, false on failure. */ - public function set_meta( $key, $value ); + public function set_meta( string $key, mixed $value ): int|bool; /** * Adds the given meta field. * - * @param string $key The meta key to get for the object. + * @param string $key The meta key to add for the object. * @param mixed $value The value to set the meta field to. + * * @return int|bool Meta ID on success, false on failure. */ - public function add_meta( $key, $value ); + public function add_meta( string $key, mixed $value ): int|bool; /** * Deletes the given meta field. * - * @param string $key The meta key to get for the object. - * @param string $value The value to set the meta field to. + * @param string $key The meta key to delete for the object. + * @param string $value Optionally limit deletion to entries with this value. + * * @return bool False for failure. True for success. */ - public function delete_meta( $key, $value = '' ); + public function delete_meta( string $key, string $value = '' ): bool; } diff --git a/src/Model/WPModelFactory.php b/src/Model/WPModelFactory.php index e7ba258..1fd2242 100644 --- a/src/Model/WPModelFactory.php +++ b/src/Model/WPModelFactory.php @@ -16,7 +16,7 @@ * * @package wpmvc */ -class WPModelFactory extends ModelFactory { +abstract class WPModelFactory extends ModelFactory { /** * Wrap an array of WP model objects using this factory's wrap() method. @@ -25,7 +25,7 @@ class WPModelFactory extends ModelFactory { * * @return array */ - public function wrap_models( array $wp_models ) { + public function wrap_models( array $wp_models ): array { $models = []; foreach ( $wp_models as $wp_model ) { @@ -44,7 +44,7 @@ public function wrap_models( array $wp_models ) { * * @return void */ - protected function apply_meta_fields( WPMeta $model, array $meta_fields ) { + protected function apply_meta_fields( WPMeta $model, array $meta_fields ): void { foreach ( $meta_fields as $key => $value ) { if ( empty( $value ) ) { $model->delete_meta( $key ); @@ -66,5 +66,5 @@ protected function apply_meta_fields( WPMeta $model, array $meta_fields ) { * * @return mixed */ - abstract public function wrap( $model ); + abstract public function wrap( mixed $model ): mixed; } diff --git a/src/Model/WPTaxonomyTerms.php b/src/Model/WPTaxonomyTerms.php index b7b68c8..6b0177c 100644 --- a/src/Model/WPTaxonomyTerms.php +++ b/src/Model/WPTaxonomyTerms.php @@ -24,39 +24,37 @@ interface WPTaxonomyTerms { * Defaults to post_tag. * @param array $args Args to pass to `wp_get_post_terms()`. * - * @return array|\WP_Error Array of terms or WP_Error if taxonomy does not - * exist + * @return array|\WP_Error Array of terms or WP_Error if taxonomy does not exist. */ - public function get_terms( $taxonomy = TaxonomyTermModel::TAXONOMY_NAME_DEFAULT, $args = [] ); + public function get_terms( string $taxonomy = TaxonomyTermModel::TAXONOMY_NAME_DEFAULT, array $args = [] ): array|\WP_Error; /** * Adds new terms to the object. * - * @param array $terms List of terms. Can be an array or a comma separated string. If you want to enter terms of a hierarchical taxonomy like - * categories, then use IDs. If you want to add non-hierarchical terms like tags, then use names. - * @param string $taxonomy Possible values for example: 'category', 'post_tag', 'taxonomy slug'. - * @param boolean $append If true, tags will be appended to the object. If false, tags will replace existing tags. + * @param array|string $terms List of terms. Can be an array or a comma separated string. + * @param string $taxonomy Possible values for example: 'category', 'post_tag', 'taxonomy slug'. + * @param bool $append If true, terms will be appended. If false, they will replace existing terms. * - * @return array|boolean|\WP_Error|string Array of terms or WP_Error if any issues occurred while processing the request. + * @return array|bool|\WP_Error Array of term IDs or WP_Error if any issues occurred. */ - public function set_terms( $terms, $taxonomy = TaxonomyTermModel::TAXONOMY_NAME_DEFAULT, $append = false ); + public function set_terms( array|string $terms, string $taxonomy = TaxonomyTermModel::TAXONOMY_NAME_DEFAULT, bool $append = false ): array|bool|\WP_Error; /** * Removes all of the terms attached to this object from the provided taxonomy. * * @param string $taxonomy The taxonomy to remove all of the terms for. * - * @return array|boolean|\WP_Error|string Array of terms or WP_Error if any issues occurred while processing the request. + * @return array|bool|\WP_Error Array of term IDs or WP_Error if any issues occurred. */ - public function remove_terms( $taxonomy = TaxonomyTermModel::TAXONOMY_NAME_DEFAULT ); + public function remove_terms( string $taxonomy = TaxonomyTermModel::TAXONOMY_NAME_DEFAULT ): array|bool|\WP_Error; /** * Removes a term from this object. * - * @param integer $term_id The ID for the term that needs to be removed. - * @param string $taxonomy The taxonomy name. + * @param int $term_id The ID for the term that needs to be removed. + * @param string $taxonomy The taxonomy name. * - * @return mixed True on success, false or WP_Error on failure. + * @return bool|\WP_Error True on success, false or WP_Error on failure. */ - public function remove_term( $term_id, $taxonomy = TaxonomyTermModel::TAXONOMY_NAME_DEFAULT ); + public function remove_term( int $term_id, string $taxonomy = TaxonomyTermModel::TAXONOMY_NAME_DEFAULT ): bool|\WP_Error; } diff --git a/src/View/EmailView.php b/src/View/EmailView.php index 28f6b7f..5c737df 100644 --- a/src/View/EmailView.php +++ b/src/View/EmailView.php @@ -36,28 +36,28 @@ class EmailView extends View { * * @var Config */ - protected $config; + protected Config $config; /** * The email template file. * * @var string */ - protected $template; + protected string $template; /** * Email headers. * * @var array */ - protected $headers = []; + protected array $headers = []; /** * Files to attach. * * @var array */ - protected $attachments = []; + protected array $attachments = []; /** * Whether the mail filters have been registered yet. @@ -72,7 +72,7 @@ class EmailView extends View { * @param Config $config App configuration object. * @param string $template The email template file. */ - public function __construct( Config $config, $template ) { + public function __construct( Config $config, string $template ) { $this->config = $config; $this->template = $template; @@ -85,7 +85,7 @@ public function __construct( Config $config, $template ) { * * @return void */ - private static function register_mail_filters() { + private static function register_mail_filters(): void { if ( self::$filters_registered ) { return; @@ -102,7 +102,7 @@ private static function register_mail_filters() { * * @param string $filename Absolute path of the file to attach. */ - public function attach( $filename ) { + public function attach( string $filename ): void { $this->attachments[] = $filename; @@ -115,7 +115,7 @@ public function attach( $filename ) { * * @return boolean Whether the email was sent correctly. NOTE does not mean that it was received properly. */ - public function send( $to ) { + public function send( string|array $to ): bool { $success = false; @@ -172,7 +172,7 @@ public function send( $to ) { * * @return string */ - protected function shortcodes( $content ) { + protected function shortcodes( string $content ): string { // Perform shortcode replacement. diff --git a/src/View/ThemeableView.php b/src/View/ThemeableView.php index 2eae861..529c1e0 100644 --- a/src/View/ThemeableView.php +++ b/src/View/ThemeableView.php @@ -35,9 +35,16 @@ class ThemeableView extends View { /** * Application config object. * - * @var object + * @var Config */ - protected $config; + protected Config $config; + + /** + * Template slug. + * + * @var string + */ + protected string $template; /** * Constructor. @@ -45,7 +52,7 @@ class ThemeableView extends View { * @param Config $config Application configuration object. * @param string $template Slug of the template to use. */ - public function __construct( Config $config, $template ) { + public function __construct( Config $config, string $template ) { $this->config = $config; $this->template = $template; @@ -55,9 +62,11 @@ public function __construct( Config $config, $template ) { * Renders the given template file. By default, it will use the template in the theme. If there is no template in * the theme, it will look in the 'template' directory in the application directory. * - * @param boolean $output Whether to output the content or return as string (default true). + * @param bool $output Whether to output the content or return as string (default true). + * + * @return string|false|null */ - public function render( $output = true ) { + public function render( bool $output = true ): string|false|null { // Build the template. $templater = new Templater( From 21cce22a4b684d630e10e720e3d03c3fba2a345f Mon Sep 17 00:00:00 2001 From: Zachary Hickson Date: Wed, 13 May 2026 00:08:49 +1000 Subject: [PATCH 12/14] Fix PHP 8.4 compatibility issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dynamic properties are deprecated since PHP 8.2 and remain E_DEPRECATED in PHP 8.4. Five model classes use __set() to intentionally forward unknown property writes as dynamic properties on the model — mark them with #[\AllowDynamicProperties] to make the intent explicit and silence the deprecation. PHP 9 will require this attribute for any class that creates dynamic properties. Also fixes two issues in EmailView: - Static property $filters_registered lacked a type declaration (untyped static properties are deprecated in PHP 8.4) - $content_templater->subject = $subject wrote a dynamic property onto Templater (same deprecation) and was also a latent bug: the value was never passed into $this->params so it was invisible to extract() inside the template. Pass it via the params array at construction instead. Co-Authored-By: Claude Sonnet 4.6 --- src/Model/CommentModel.php | 1 + src/Model/GenericPostModel.php | 1 + src/Model/TaxonomyModel.php | 1 + src/Model/TaxonomyTermModel.php | 1 + src/Model/UserModel.php | 1 + src/View/EmailView.php | 9 +++++---- 6 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/Model/CommentModel.php b/src/Model/CommentModel.php index e3425f5..8dee2cb 100644 --- a/src/Model/CommentModel.php +++ b/src/Model/CommentModel.php @@ -13,6 +13,7 @@ * A model instance which wraps a `WP_Comment` instance. * You should extend this class for a comment model type. I.e. for comments attached to a specific CPT. */ +#[\AllowDynamicProperties] class CommentModel extends WPModel implements WPMeta { /** diff --git a/src/Model/GenericPostModel.php b/src/Model/GenericPostModel.php index a43f5c3..0ced15d 100644 --- a/src/Model/GenericPostModel.php +++ b/src/Model/GenericPostModel.php @@ -38,6 +38,7 @@ * @property string $comment_count Number of comments on post (numeric string) * @package wpmvc */ +#[\AllowDynamicProperties] class GenericPostModel extends WPModel implements WPMeta, WPTaxonomyTerms { // Post Fields. diff --git a/src/Model/TaxonomyModel.php b/src/Model/TaxonomyModel.php index 1b7dc35..edb50da 100644 --- a/src/Model/TaxonomyModel.php +++ b/src/Model/TaxonomyModel.php @@ -14,6 +14,7 @@ * * @package wpmvc */ +#[\AllowDynamicProperties] class TaxonomyModel extends WPModel { /** diff --git a/src/Model/TaxonomyTermModel.php b/src/Model/TaxonomyTermModel.php index 544180c..03ee3b3 100644 --- a/src/Model/TaxonomyTermModel.php +++ b/src/Model/TaxonomyTermModel.php @@ -25,6 +25,7 @@ * @property string $filter * @package wpmvc */ +#[\AllowDynamicProperties] class TaxonomyTermModel extends WPModel implements WPMeta { const TAXONOMY_NAME_POST_TAG = 'post_tag'; diff --git a/src/Model/UserModel.php b/src/Model/UserModel.php index a121d84..1b221c1 100644 --- a/src/Model/UserModel.php +++ b/src/Model/UserModel.php @@ -35,6 +35,7 @@ * @property string $locale * TODO document the user properties. */ +#[\AllowDynamicProperties] class UserModel extends WPModel implements WPMeta { const FIELD_USER_LOGIN = 'user_login'; diff --git a/src/View/EmailView.php b/src/View/EmailView.php index 5c737df..3cccbda 100644 --- a/src/View/EmailView.php +++ b/src/View/EmailView.php @@ -64,7 +64,7 @@ class EmailView extends View { * * @var bool */ - private static $filters_registered = false; + private static bool $filters_registered = false; /** * Constructor. @@ -141,16 +141,17 @@ public function send( string|array $to ): bool { $subject = $subject_templater->render( false ); // Build the email content template. + $content_params = $this->params; + $content_params['subject'] = $subject; + $content_templater = new Templater( [ 'slug' => $this->template, 'dir' => $this->config->get_app_directory(), - 'params' => $this->params, + 'params' => $content_params, ] ); - $content_templater->subject = $subject; - $content = $content_templater->render( false ); // Now send the email. From 1c77a01e5d294f216e1c9f9248b3013638df8fe3 Mon Sep 17 00:00:00 2001 From: Zachary Hickson Date: Wed, 13 May 2026 00:16:15 +1000 Subject: [PATCH 13/14] Update CHANGELOG for v1.1.0 Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index daa128f..22232d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### TBD +## [1.1.0] - 2026-05-13 + +### Added + +- `AdminAjax::endpoint()` gains an `$auth_only` parameter; when `true`, the `wp_ajax_nopriv_` hook is not registered, restricting the action to authenticated users only. +- `AdminAjax::verify_nonce()` static helper — verifies a nonce from `$_POST` and calls `wp_die()` on failure. +- `WPModelFactory::wrap_models()` and `WPModelFactory::apply_meta_fields()` as base implementations, eliminating duplicated code across all four factory subclasses. `wrap()` is now abstract. + +### Changed + +- `WPModelFactory::wrap()` is now `abstract` — subclasses that previously relied on the no-op base must implement it explicitly. +- `WPMeta` interface method signatures updated to use strict PHP 8.1 types (`?string $key`, union return types). +- `WPTaxonomyTerms` interface method signatures updated to use strict PHP 8.1 types. +- PHP 8.1 typed properties, parameter types, and return types added throughout the framework. +- All model classes (`GenericPostModel`, `UserModel`, `TaxonomyModel`, `TaxonomyTermModel`, `CommentModel`) annotated with `#[\AllowDynamicProperties]` for intentional dynamic property use. +- `gettype()` checks replaced with `instanceof` across all model classes and factories. +- `isset`/ternary patterns replaced with null coalescing (`??`) throughout. + +### Fixed + +- `CommentModelFactory`: fixed typo `$commend` → `$comment` (caused undefined variable on lookup failure); `create_comment()` now passes the `$comment` argument; `delete_comment()` now uses `comment_ID`; `update_comment()` captures the return value. +- `Config::get()` no longer overwrites `$default` before it is used; `glob()` calls fall back to `[]` instead of `false`. +- `Route`: `REQUEST_URI` sanitised via `sanitize_url(wp_unslash(...))` and parsed with `parse_url()` instead of `strtok()`; match check corrected from `!empty($matches)` to checking `$match` directly. +- `REST`: `extract()` calls removed and replaced with explicit key access; `_doing_it_wrong()` is now emitted when `permission_callback` is absent, discouraging open endpoints. +- `EmailView`: `$content_templater->subject` dynamic property assignment replaced — subject is now passed through `$content_params` so it is available inside the email body template. +- `Templater`: constructor bug where `$this->params['template_slug']` referenced a removed local variable; corrected to `$this->slug`. + +### Security + +- `REQUEST_URI` in `Route` sanitised with `sanitize_url(wp_unslash(...))` before use. +- `WP_ENV` in `Config` restricted to `[a-zA-Z0-9_-]` via `preg_replace` to prevent path traversal in config file lookups. +- `EmailView::shortcodes()` switched from `preg_replace` with `$1`/`$2` backreferences to `preg_replace_callback`, eliminating potential backreference injection via user-controlled param values. +- `REST` emits `_doing_it_wrong()` when `permission_callback` is not provided, discouraging inadvertently public endpoints. ## [1.0.0-alpha] - 2024-11-01 @@ -16,5 +48,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial package release v1.0.0-alpha. -[unreleased]: https://github.com/TheCodeCompany/wpmvc/compare/v1.0.0-alpha...HEAD +[unreleased]: https://github.com/TheCodeCompany/wpmvc/compare/v1.1.0...HEAD +[1.1.0]: https://github.com/TheCodeCompany/wpmvc/compare/v1.0.0-alpha...v1.1.0 [1.0.0-alpha]: https://github.com/TheCodeCompany/wpmvc/releases/tag/v1.0.0-alpha From 9ef6dd846beca832933097a7c09c7611213e6e85 Mon Sep 17 00:00:00 2001 From: Zachary Hickson Date: Wed, 13 May 2026 00:28:57 +1000 Subject: [PATCH 14/14] Fix performance issues and logic bugs found in audit - Application: collapse three controller setup loops into one pass, cutting apply_filters() calls from 6N to 6 per controller and fixing a bug where filter-replaced instances were lost between loops - REST: remove redundant current_request assignment and set_current_endpoint() call in handle_callback(); handle_perm_callback() always runs first so the work was already done, wasting a full endpoint regex scan per request - EmailView: fix send() returning false for all array-recipient calls regardless of outcome; now returns true only if every recipient succeeds Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 3 +++ src/Core/Application.php | 34 +++++----------------------------- src/Library/REST.php | 6 ------ src/View/EmailView.php | 5 ++++- 4 files changed, 12 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22232d2..6aed526 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- `Application::setup_controllers()` collapsed from three separate controller loops into one, eliminating 6 `apply_filters()` calls per controller per request and fixing a correctness bug where filter-replaced controller instances were not persisted back to `$this->controllers`, causing later loops to operate on the original (pre-filter) instance. +- `REST::handle_callback()` no longer redundantly re-assigns `$this->current_request` and re-calls `set_current_endpoint()` — both were already set by `handle_perm_callback()`, which WordPress always runs first; this eliminates a full endpoint scan on every matched REST request. +- `EmailView::send()` now correctly returns `false` if any recipient fails when passed an array of addresses; previously the return value of each recursive call was discarded and the method always returned `false` for multi-recipient sends regardless of outcome. - `CommentModelFactory`: fixed typo `$commend` → `$comment` (caused undefined variable on lookup failure); `create_comment()` now passes the `$comment` argument; `delete_comment()` now uses `comment_ID`; `update_comment()` captures the return value. - `Config::get()` no longer overwrites `$default` before it is used; `glob()` calls fall back to `[]` instead of `false`. - `Route`: `REQUEST_URI` sanitised via `sanitize_url(wp_unslash(...))` and parsed with `parse_url()` instead of `strtok()`; match check corrected from `!empty($matches)` to checking `$match` directly. diff --git a/src/Core/Application.php b/src/Core/Application.php index 7e92ec9..a342105 100644 --- a/src/Core/Application.php +++ b/src/Core/Application.php @@ -89,53 +89,29 @@ public function setup_controllers(): void { $route = new Route(); $rest = new REST(); $ajax = new AdminAjax(); - foreach ( $this->controllers as $controller ) { - /** - * Apply filters to the controller before we set instances. - */ + foreach ( $this->controllers as $index => $controller ) { + $controller = apply_filters( 'wpmvc_pre_controller_set_instances', $controller ); $controller->set_route_instance( $route ); $controller->set_rest_instance( $rest ); $controller->set_admin_ajax_instance( $ajax ); - /** - * Apply filters to the controller after we set instances. - */ $controller = apply_filters( 'wpmvc_post_controller_set_instances', $controller ); - } - - // Set config. - foreach ( $this->controllers as $controller ) { - - /** - * Apply filters to the controller before we set the config. - */ $controller = apply_filters( 'wpmvc_pre_controller_set_config', $controller ); $controller->set_config_instance( $this->config ); - /** - * Apply filters to the controller after we set the config. - */ $controller = apply_filters( 'wpmvc_post_controller_set_config', $controller ); - } - - // Set up each controller. - foreach ( $this->controllers as $controller ) { - - /** - * Apply filters to the controller before we set it up. - */ $controller = apply_filters( 'wpmvc_pre_controller_set_up', $controller ); $controller->set_up(); - /** - * Apply filters to the controller after we set it up. - */ $controller = apply_filters( 'wpmvc_post_controller_set_up', $controller ); + + // Write back so filter-replaced instances are persisted for callers. + $this->controllers[ $index ] = $controller; } } diff --git a/src/Library/REST.php b/src/Library/REST.php index 4223525..dc09705 100644 --- a/src/Library/REST.php +++ b/src/Library/REST.php @@ -227,12 +227,6 @@ public function handle_callback( \WP_REST_Request $request ): mixed { $response = []; - // Save current request. - $this->current_request = $request; - - // Set current end point. - $this->set_current_endpoint( $request ); - // Call user callback. if ( ! empty( $this->current_endpoint ) ) { diff --git a/src/View/EmailView.php b/src/View/EmailView.php index 3cccbda..b8c12cc 100644 --- a/src/View/EmailView.php +++ b/src/View/EmailView.php @@ -122,8 +122,11 @@ public function send( string|array $to ): bool { // Recurse if the to field is an array of email addresses. if ( is_array( $to ) ) { + $success = true; foreach ( $to as $recipient ) { - $this->send( $recipient ); + if ( ! $this->send( $recipient ) ) { + $success = false; + } } return $success;