diff --git a/src/Carbon/Traits/Mixin.php b/src/Carbon/Traits/Mixin.php index 3aedfa24..8167b96c 100644 --- a/src/Carbon/Traits/Mixin.php +++ b/src/Carbon/Traits/Mixin.php @@ -21,6 +21,7 @@ use ReflectionClass; use ReflectionException; use ReflectionMethod; +use ReflectionNamedType; use Throwable; /** @@ -79,7 +80,7 @@ private static function loadMixinClass(object|string $mixin): void ); foreach ($methods as $method) { - if ($method->isConstructor() || $method->isDestructor()) { + if (self::cannotBeAMixinMethod($method)) { continue; } @@ -91,6 +92,36 @@ private static function loadMixinClass(object|string $mixin): void } } + private static function cannotBeAMixinMethod(ReflectionMethod $method): bool + { + if ($method->isConstructor() || $method->isDestructor()) { + return true; + } + + $returnType = $method->getReturnType(); + + if ($returnType instanceof ReflectionNamedType) { + $returnedTypeName = $returnType->getName(); + + if ($returnType->isBuiltin()) { + return !\in_array($returnedTypeName, [ + 'callable', + 'object', // could have __invoke + 'array', // could be [MyClass::class, 'myMethod'] + 'mixed', // could be one of the above + // The other builtin types cannot be callable, so we can skip invoking them + ], true); + } + + // If it returns a non-invokable object, it cannot be a mixin method + if (class_exists($returnedTypeName)) { + return !\is_callable([$returnedTypeName, '__invoke']); + } + } + + return false; + } + private static function loadMixinTrait(string $trait): void { $context = eval(self::getAnonymousClassCodeForTrait($trait)); diff --git a/tests/CarbonImmutable/MacroTest.php b/tests/CarbonImmutable/MacroTest.php index f30a0b42..2952de09 100644 --- a/tests/CarbonImmutable/MacroTest.php +++ b/tests/CarbonImmutable/MacroTest.php @@ -16,6 +16,7 @@ use BadMethodCallException; use Carbon\CarbonImmutable as Carbon; use CarbonTimezoneTrait; +use Closure; use PHPUnit\Framework\Attributes\RequiresPhpExtension; use SubCarbonImmutable; use Tests\AbstractTestCaseWithOldNow; @@ -172,4 +173,95 @@ public function testSubClassMacro() SubCarbonImmutable::resetMacros(); } + + public function testLazyMixinMethodCall() + { + $test = new class () { + public static array $calledMethods = []; + + public function __construct() + { + self::$calledMethods[] = __METHOD__; + } + + public function __destruct() + { + self::$calledMethods[] = __METHOD__; + } + + public static function noReturnType() + { + self::$calledMethods[] = __METHOD__; + + return static fn () => 'foo'; + } + + public static function returnVoid(): void + { + self::$calledMethods[] = __METHOD__; + } + + public static function returnArray(): array + { + self::$calledMethods[] = __METHOD__; + + return []; + } + + public static function returnObject(): object + { + self::$calledMethods[] = __METHOD__; + + return (object) []; + } + + public static function returnClosure(): Closure + { + self::$calledMethods[] = __METHOD__; + + return static fn () => 'foo'; + } + + public static function returnMixed(): mixed + { + self::$calledMethods[] = __METHOD__; + + return static fn () => 'foo'; + } + + public static function returnOtherBuiltIn(): bool + { + self::$calledMethods[] = __METHOD__; + + return true; + } + + public static function returnUnion(): bool|array + { + self::$calledMethods[] = __METHOD__; + + return true; + } + + public static function getCalledMethods(): array + { + return self::$calledMethods; + } + }; + + Carbon::mixin($test); + Carbon::resetMacros(); + + $this->assertSame([ + '__construct', // Only happening because of $test = new class()... but none from Carbon::mixin() + 'noReturnType', + 'returnArray', + 'returnObject', + 'returnMixed', + 'returnUnion', + ], array_map( + static fn (string $name) => explode('::', $name, 2)[1], + $test::getCalledMethods(), + )); + } }