diff --git a/CHANGELOG.md b/CHANGELOG.md index ad54bf1f0..beacf8c51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Updated the `Mantle\Support\Helpers\defer` helper to be able to used outside of the Mantle Framework via the `shutdown` hook. +### Fixed + +- Allow `Filter`/`Action` attributes to be used multiple times on the same method. + ## v1.3.2 - 2024-12-17 - Allow stray requests to be ignored and pass through when stray requests are being prevented. diff --git a/src/mantle/support/attributes/class-action.php b/src/mantle/support/attributes/class-action.php index ead14ada1..a5bccaa03 100644 --- a/src/mantle/support/attributes/class-action.php +++ b/src/mantle/support/attributes/class-action.php @@ -14,7 +14,7 @@ * * Used to hook a method to an WordPress hook at a specific priority. */ -#[Attribute] +#[Attribute( Attribute::IS_REPEATABLE | Attribute::TARGET_METHOD | Attribute::TARGET_FUNCTION )] class Action { /** * Constructor. diff --git a/src/mantle/support/attributes/class-filter.php b/src/mantle/support/attributes/class-filter.php index ac19fb9bb..778ed52cb 100644 --- a/src/mantle/support/attributes/class-filter.php +++ b/src/mantle/support/attributes/class-filter.php @@ -14,7 +14,7 @@ * * Used to hook a method to an WordPress hook at a specific priority. */ -#[Attribute] +#[Attribute( Attribute::IS_REPEATABLE | Attribute::TARGET_METHOD | Attribute::TARGET_FUNCTION )] class Filter { /** * Constructor. diff --git a/src/mantle/support/traits/trait-hookable.php b/src/mantle/support/traits/trait-hookable.php index f8eb27dba..a64b020c6 100644 --- a/src/mantle/support/traits/trait-hookable.php +++ b/src/mantle/support/traits/trait-hookable.php @@ -24,6 +24,11 @@ * the respective WordPress hooks. */ trait Hookable { + /** + * Flag to determine if the hooks have been registered. + */ + protected bool $hooks_registered = false; + /** * Constructor (can be overridden by the trait user). */ @@ -40,6 +45,10 @@ public function __construct() { * respective WordPress hooks. */ protected function register_hooks(): void { + if ( $this->hooks_registered ) { + return; + } + $this->collect_action_methods() ->merge( $this->collect_attribute_hooks() ) ->unique() @@ -51,16 +60,20 @@ function ( array $item ): void { } else { \Mantle\Support\Helpers\add_filter( $item['hook'], [ $this, $item['method'] ], $item['priority'] ); } - } else { // phpcs:ignore Universal.ControlStructures.DisallowLonelyIf.Found - // Use the default WordPress action/filter methods. - if ( 'action' === $item['type'] ) { - \add_action( $item['hook'], [ $this, $item['method'] ], $item['priority'], 999 ); - } else { - \add_filter( $item['hook'], [ $this, $item['method'] ], $item['priority'], 999 ); - } + + return; + } + + // Use the default WordPress action/filter methods. + if ( 'action' === $item['type'] ) { + \add_action( $item['hook'], [ $this, $item['method'] ], $item['priority'], 999 ); + } else { + \add_filter( $item['hook'], [ $this, $item['method'] ], $item['priority'], 999 ); } }, ); + + $this->hooks_registered = true; } /** diff --git a/tests/Support/HookableAttributeTest.php b/tests/Support/HookableAttributeTest.php new file mode 100644 index 000000000..9f5dfbff9 --- /dev/null +++ b/tests/Support/HookableAttributeTest.php @@ -0,0 +1,168 @@ +assertFalse( $_SERVER['__hook_fired'] ); + + do_action( 'example_action', 'foo' ); + + $this->assertSame( 'foo', $_SERVER['__hook_fired'] ); + } + + public function test_action_from_method_name_with_priority(): void { + + $_SERVER['__hook_fired'] = []; + + $class = new class { + use Hookable; + + #[Action( 'example_action', 20 )] + public function action_at_20( mixed $args ): void { + $_SERVER['__hook_fired'][] = 20; + } + + #[Action( 'example_action' )] + public function action_at_10( mixed $args ): void { + $_SERVER['__hook_fired'][] = 10; + } + + }; + + // Remove the action that was added by creating the anonymous class. + remove_all_actions( 'example_action' ); + + new $class; + + $this->assertEmpty( $_SERVER['__hook_fired'] ); + + do_action( 'example_action', 'foo' ); + + $this->assertSame( [ 10, 20 ], $_SERVER['__hook_fired'] ); + } + + public function test_filter_from_method_name(): void { + $_SERVER['__hook_fired'] = false; + + $class = new class { + use Hookable; + + #[Filter( 'example_action' )] + public function filter_the_value( mixed $value ): mixed { + $_SERVER['__hook_fired'] = $value; + + return 'bar'; + } + }; + + remove_all_filters( 'example_action' ); + + new $class; + + $this->assertFalse( $_SERVER['__hook_fired'] ); + + $value = apply_filters( 'example_action', 'foo' ); + + $this->assertSame( 'foo', $_SERVER['__hook_fired'] ); + $this->assertSame( 'bar', $value ); + } + + public function test_filter_from_method_name_with_priority(): void { + $_SERVER['__hook_fired'] = []; + + $class = new class { + use Hookable; + + #[Filter( 'example_action', priority: 20 )] + public function filter_at_20( int $value ): int { + $_SERVER['__hook_fired'][] = $value; + + return $value + 20; + } + + #[Filter( 'example_action' )] + public function filter_at_10( int $value ): int { + $_SERVER['__hook_fired'][] = $value; + + return $value + 10; + } + + }; + + // Remove the action that was added by creating the anonymous class. + remove_all_actions( 'example_action' ); + + new $class; + + $this->assertEmpty( $_SERVER['__hook_fired'] ); + + $value = apply_filters( 'example_action', 5 ); + + $this->assertSame( [ 5, 15 ], $_SERVER['__hook_fired'] ); + $this->assertSame( 35, $value ); + } + + public function test_multiple_filters_on_one_method(): void { + $_SERVER['__hook_fired'] = []; + + $class = new class { + use Hookable; + + #[Filter( 'another_filter' )] + #[Filter( 'example_action' )] + public function filter_to_call( int $value ): int { + $_SERVER['__hook_fired'][] = $value; + + return $value + 20; + } + }; + + // Remove the action that was added by creating the anonymous class. + remove_all_actions( 'another_filter' ); + remove_all_actions( 'example_action' ); + + new $class; + + $this->assertTrue( has_filter( 'another_filter' ) ); + $this->assertTrue( has_filter( 'example_action' ) ); + + $this->assertEmpty( $_SERVER['__hook_fired'] ); + + $value = apply_filters( 'example_action', 5 ); + + $this->assertSame( [ 5 ], $_SERVER['__hook_fired'] ); + $this->assertSame( 25, $value ); + + $_SERVER['__hook_fired'] = []; + + $value = apply_filters( 'another_filter', 10 ); + + $this->assertSame( [ 10 ], $_SERVER['__hook_fired'] ); + $this->assertSame( 30, $value ); + } +} diff --git a/tests/Support/HookableTest.php b/tests/Support/HookableTest.php new file mode 100644 index 000000000..3c4d82b17 --- /dev/null +++ b/tests/Support/HookableTest.php @@ -0,0 +1,119 @@ +assertFalse( $_SERVER['__hook_fired'] ); + + do_action( 'example_action', 'foo' ); + + $this->assertSame( 'foo', $_SERVER['__hook_fired'] ); + } + + public function test_action_from_method_name_with_priority(): void { + + $_SERVER['__hook_fired'] = []; + + $class = new class { + use Hookable; + + public function action__example_action_at_20( mixed $args ): void { + $_SERVER['__hook_fired'][] = 20; + } + + public function action__example_action_at_10( mixed $args ): void { + $_SERVER['__hook_fired'][] = 10; + } + }; + + // Remove the action that was added by creating the anonymous class. + remove_all_actions( 'example_action' ); + + new $class; + + $this->assertEmpty( $_SERVER['__hook_fired'] ); + + do_action( 'example_action', 'foo' ); + + $this->assertSame( [ 10, 20 ], $_SERVER['__hook_fired'] ); + } + + public function test_filter_from_method_name(): void { + $_SERVER['__hook_fired'] = false; + + $class = new class { + use Hookable; + + public function filter__example_action( mixed $value ): mixed { + $_SERVER['__hook_fired'] = $value; + + return 'bar'; + } + }; + + remove_all_filters( 'example_action' ); + + new $class; + + $this->assertFalse( $_SERVER['__hook_fired'] ); + + $value = apply_filters( 'example_action', 'foo' ); + + $this->assertSame( 'foo', $_SERVER['__hook_fired'] ); + $this->assertSame( 'bar', $value ); + } + + public function test_filter_from_method_name_with_priority(): void { + $_SERVER['__hook_fired'] = []; + + $class = new class { + use Hookable; + + public function filter__example_action_at_20( int $value ): int { + $_SERVER['__hook_fired'][] = $value; + + return $value + 20; + } + + public function filter__example_action_at_10( int $value ): int { + $_SERVER['__hook_fired'][] = $value; + + return $value + 10; + } + }; + + // Remove the action that was added by creating the anonymous class. + remove_all_actions( 'example_action' ); + + new $class; + + $this->assertEmpty( $_SERVER['__hook_fired'] ); + + $value = apply_filters( 'example_action', 5 ); + + $this->assertSame( [ 5, 15 ], $_SERVER['__hook_fired'] ); + $this->assertSame( 35, $value ); + } +}