diff --git a/CHANGELOG.md b/CHANGELOG.md index daa128f..6aed526 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,42 @@ 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 + +- `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. +- `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 +51,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 diff --git a/src/Core/Application.php b/src/Core/Application.php index 9e16bd5..a342105 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; @@ -37,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. @@ -67,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; @@ -84,59 +83,35 @@ public function __construct( $name, $directory, $controllers ) { * * @return void */ - public function setup_controllers() { + public function setup_controllers(): void { // Set helper instances. $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 ); // TODO: implement and test. + $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; } } @@ -145,7 +120,7 @@ public function setup_controllers() { * * @return Config */ - public function get_config() { + public function get_config(): Config { return $this->config; } @@ -155,7 +130,7 @@ public function get_config() { * * @return string */ - public function get_directory() { + public function get_directory(): string { return $this->directory; } @@ -165,7 +140,7 @@ public function get_directory() { * * @return string */ - public function get_name() { + public function get_name(): string { return $this->name; } @@ -175,7 +150,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 d7b522e..f33d274 100644 --- a/src/Library/AdminAjax.php +++ b/src/Library/AdminAjax.php @@ -16,18 +16,25 @@ * $ajax->endpoint( 'my_ajax_action', [ $this, 'my_callback' ] ); */ class AdminAjax { - // TODO: make singleton. /** * 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. + * + * @return void */ - public function endpoint( $action, $callback ) { + public function endpoint( string $action, callable $callback, bool $auth_only = false ): void { add_action( 'wp_ajax_' . $action, $callback ); - add_action( 'wp_ajax_nopriv_' . $action, $callback ); + + if ( ! $auth_only ) { + add_action( 'wp_ajax_nopriv_' . $action, $callback ); + } } /** @@ -36,21 +43,39 @@ public function endpoint( $action, $callback ) { * @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( string $param, mixed $default = '' ): mixed { + + return $_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 get_param( $param, $default = '' ) { + public static function verify_nonce( string $action, string $nonce_key = '_wpnonce' ): void { - $value = isset( $_POST[ $param ] ) ? $_POST[ $param ] : $default; // phpcs:ignore + $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 ); + } } /** * 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 ed7123f..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,28 +60,21 @@ 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 { - $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; } /** @@ -88,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(); } @@ -98,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 = []; @@ -114,7 +110,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,33 +120,27 @@ 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; } // 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 ) { - $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 0c4e377..dc09705 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,20 +86,23 @@ 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 ) { - - $default_args = [ - 'namespace' => '', - 'version' => 'v1', - 'action' => '', - 'method' => 'GET', - 'callback' => '', - 'permission_callback' => '', - 'args' => [], - ]; - - $args = array_merge( $default_args, $args ); + public function endpoint( array $args ): void { + + $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 ); @@ -109,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; } @@ -120,26 +123,36 @@ public function get_request() { * * @return void */ - public function register_endpoints() { + public function register_endpoints(): void { 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'], ] ); - } } @@ -148,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. @@ -163,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() @@ -189,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 ) { @@ -211,18 +221,12 @@ 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 = []; - // Save current request. - $this->current_request = $request; - - // Set current end point. - $this->set_current_endpoint( $request ); - // Call user callback. if ( ! empty( $this->current_endpoint ) ) { @@ -249,13 +253,7 @@ 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; + 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 4b06d19..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,30 +73,30 @@ 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'] ) ? $_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. $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 ); - // Regsiter each of the routes. + // Register each of the routes. foreach ( $this->routes as $route ) { $regex = $route['regex']; $callback = $route['callback']; @@ -107,9 +106,9 @@ 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! + // 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 16ac66c..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. @@ -92,27 +92,13 @@ 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; + $this->params['template_slug'] = $this->slug; } /** @@ -123,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; } @@ -134,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 { @@ -157,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 = [ @@ -201,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 84e751c..8dee2cb 100644 --- a/src/Model/CommentModel.php +++ b/src/Model/CommentModel.php @@ -13,14 +13,15 @@ * 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 { /** * Wrapped WP_Comment * - * @var \WP_Comment + * @var \WP_Comment|null */ - protected $comment; + protected ?\WP_Comment $comment; /** * CommentModel constructor. @@ -29,7 +30,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 ); @@ -45,14 +46,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 +59,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 +77,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 +90,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 +103,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/CommentModelFactory.php b/src/Model/CommentModelFactory.php index 064153a..9d12b27 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 ); } /** @@ -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/GenericPostModel.php b/src/Model/GenericPostModel.php index 271e509..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. @@ -87,9 +88,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. @@ -98,8 +99,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 ); } @@ -115,14 +116,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 +132,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 +143,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 +161,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 +174,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 +187,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 +200,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 +213,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 +226,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 +243,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 +257,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 +268,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 +280,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/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/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 7352c3c..edb50da 100644 --- a/src/Model/TaxonomyModel.php +++ b/src/Model/TaxonomyModel.php @@ -14,14 +14,15 @@ * * @package wpmvc */ +#[\AllowDynamicProperties] 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. @@ -29,9 +30,8 @@ class TaxonomyModel extends WPModel { * @param null|\WP_Taxonomy|string $taxonomy The taxonomy instance. */ 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 ); @@ -45,7 +45,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 ) ) { @@ -63,7 +63,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 { @@ -76,7 +76,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 a802f97..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'; @@ -36,9 +37,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. @@ -46,9 +47,8 @@ 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 ( 'object' === (string) gettype( $term ) ) { + if ( $term instanceof \WP_Term ) { $this->term = $term; } else { $this->term = get_term( $term ); @@ -62,7 +62,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 ) ) { @@ -80,7 +80,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 { @@ -93,7 +93,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; } @@ -105,8 +105,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 ) { - // assert( ! empty( $args ) ); + public function update( array $args ): array|\WP_Error { $term_id = $this->term->term_id; $taxonomy = $this->term->taxonomy; @@ -122,39 +121,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/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/UserModel.php b/src/Model/UserModel.php index bb6796b..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'; @@ -45,9 +46,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. @@ -55,9 +56,8 @@ 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 ( 'object' === (string) gettype( $user ) ) { + if ( $user instanceof \WP_User ) { $this->user = $user; } else { $this->user = get_user_by( 'id', $user ); @@ -71,7 +71,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 ) ) { @@ -89,7 +89,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 { @@ -105,7 +105,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 ) ) { @@ -122,7 +122,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; } @@ -134,8 +134,7 @@ public function get_wp_user() { * @return int|\WP_Error * @deprecated Use a factory to update a model. */ - public function update( $args ) { - // assert( ! empty( $args ) ); + public function update( array $args ): int|\WP_Error { $args['ID'] = $this->user->ID; return wp_update_user( $args ); @@ -149,7 +148,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 ); } @@ -161,8 +160,7 @@ public function get_meta( $key = null, $single = true ) { * * @return bool|int */ - public function set_meta( $key, $value ) { - // assert( ! empty( $key ) ); + public function set_meta( string $key, mixed $value ): int|bool { return update_user_meta( $this->user->ID, $key, $value ); } @@ -176,8 +174,7 @@ public function set_meta( $key, $value ) { * * @return false|int */ - public function add_meta( $key, $value, $unique = false ) { - // assert( ! empty( $key ) ); + public function add_meta( string $key, mixed $value, bool $unique = false ): int|bool { return add_user_meta( $this->user->ID, $key, $value, $unique ); } @@ -190,8 +187,7 @@ 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 ) ); + public function delete_meta( string $key, string $value = '' ): bool { return delete_user_meta( $this->user->ID, $key, $value ); } @@ -201,7 +197,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 b9c516e..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; @@ -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/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 8c75163..1fd2242 100644 --- a/src/Model/WPModelFactory.php +++ b/src/Model/WPModelFactory.php @@ -16,6 +16,55 @@ * * @package wpmvc */ -class WPModelFactory extends ModelFactory { +abstract 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 ): array { + $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 ): void { + 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( 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 6f713e7..b8c12cc 100644 --- a/src/View/EmailView.php +++ b/src/View/EmailView.php @@ -36,28 +36,35 @@ 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. + * + * @var bool + */ + private static bool $filters_registered = false; /** * Constructor. @@ -65,25 +72,29 @@ 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; - // 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(): void { + + 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; } /** @@ -91,9 +102,8 @@ function () { * * @param string $filename Absolute path of the file to attach. */ - public function attach( $filename ) { + public function attach( string $filename ): void { - // assert( file_exists( $filename ) ); $this->attachments[] = $filename; } @@ -105,16 +115,18 @@ 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; - // assert( ! empty( $to ) ); // 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; @@ -132,16 +144,17 @@ public function send( $to ) { $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. @@ -163,18 +176,20 @@ public function send( $to ) { * * @return string */ - protected function shortcodes( $content ) { + protected function shortcodes( string $content ): string { - // assert( ! empty( $content ) ); // Perform shortcode replacement. foreach ( $this->params as $key => $value ) { 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 ); 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(