Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[11.x] Fix Macroable trait conflicting with class magic methods #54885

Closed
wants to merge 8 commits into from

Conversation

rsd
Copy link

@rsd rsd commented Mar 3, 2025

Problem

Adding the Macroable trait to a class that already implements __call and
__callStatic breaks core functionality. This is especially problematic for
Eloquent models, where methods like create() and hydrate() fail, but
it affects any class using these magic methods.

Reproduction: A full demonstration of this issue can be found at
https://github.com/rsd/macroable-test.

Root Cause

  • Both the base class and Macroable implement __call and __callStatic.
  • PHP gives precedence to the trait's implementation.
  • Macroable does not attempt to delegate to the parent class.

Solution

Updated Macroable's magic methods to check if the parent class can handle
the method
before throwing an exception. This ensures:

  • Macro functionality is preserved.
  • Original class methods continue to work.

Testing

  • Added tests using Eloquent models as a real-world example.
  • Verified that both standard Eloquent methods and custom macros
    work together correctly.

Impact

This fix is critical for:

  • Eloquent models that use Macroable.
  • Other Laravel components relying on magic methods (Collections, Query Builder, etc.).
  • Any class using Macroable alongside magic methods.
  • Modular Laravel applications, where modules extend core models dynamically.

Without this fix, developers must choose between using Macroable or keeping
the class’s original magic method functionality. This is especially problematic
for modular Laravel systems, where extending models dynamically is essential.

rsd added 8 commits March 3, 2025 19:47
…odels

When an Eloquent model uses the Macroable trait, core functionality breaks due to
conflicting magic method implementations. Macroable's `__call` and `__callStatic`
override Eloquent's methods, causing exceptions when internal methods aren't macros.

This test demonstrates two failures:
- Static methods like `create()` fail with `BadMethodCallException`.
- Query methods that depend on `hydrate()` also fail with `BadMethodCallException`.

This issue affects modular applications that extend Eloquent models dynamically.
…ic methods

This fixes an issue where adding the Macroable trait to Eloquent models
breaks core functionality. The conflict arises because both Eloquent's
Model class and Macroable implement `__call` and `__callStatic`, and PHP
gives precedence to the trait's implementation.

Issue details:
- Methods like `create()`, `find()`, and `hydrate()` were intercepted by Macroable.
- Since these weren't registered as macros, `BadMethodCallException` was thrown.

Fix implemented:
- Updated Macroable's magic methods to check if the parent can handle the call
  before throwing an exception.
- Ensures compatibility between dynamic macros and Eloquent's method resolution.

Impact:
- Prevents breaking core Eloquent functionality in modular applications.
- Allows extending models dynamically while preserving Eloquent behavior.

Tests:
- Added test cases to validate that both Eloquent methods and custom macros work.
…rovider

Updated the Macroable trait test to ensure compatibility with macros registered
via a ServiceProvider. This improves test coverage by validating that macros
added in a ServiceProvider are recognized by Eloquent models.
Reformatted the test files to conform to Laravel Pint's coding style standards.
This ensures consistency across the codebase and improves readability.
…heritance

Updated method resolution by replacing `method_exists()` with `is_callable()`
to better support multi-level inheritance. This ensures that overridden methods
in parent classes are properly detected.

Additional improvements:
- Updated return type checking for better type safety.
Reformatted the test files to conform to Laravel Pint's coding style standards.
This ensures consistency across the codebase and improves readability.
…gration checks

Refactored the logic by replacing explicit integration error checks with a
`try-catch` block. This simplifies error handling and improves maintainability.
Fixed PHPStan errors related to parent method calls in the `Macroable` trait
by improving method existence checks and adding PHPStan ignore directives.

Changes include:
- Checked each parent class for `__call` and `__callStatic` before invoking them.
- Ensured parent methods are only called if they exist.
- Added PHPStan ignore directives for specific error types:
  - `class.noParent` for cases where the class has no parent.
  - `staticMethod.notFound` for cases where the parent doesn't have the method.
@Rizky92
Copy link

Rizky92 commented Mar 4, 2025

This is more of a hassle now instead of convenient. Macroable should be a drop in Trait and function as is. If the parent class have __call or __callStatic, it's better to just override them in child class instead.

@rsd
Copy link
Author

rsd commented Mar 4, 2025

This is more of a hassle now instead of convenient. Macroable should be a drop in Trait and function as is. If the parent class have __call or __callStatic, it's better to just override them in child class instead.

I opened an issue to discuss it further and present my point better. #54887

There are some cases, like splitting models relationships into different modules / packages, that there is no easy way around it.

And the solution is about 3 lines per method.

@calebdw
Copy link
Contributor

calebdw commented Mar 4, 2025

Why are you trying to macro a specific model? If you already have a child model then just add the method to it...

@rsd
Copy link
Author

rsd commented Mar 4, 2025

Why are you trying to macro a specific model? If you already have a child model then just add the method to it...

If the child model is optional, in a different module/package it can't be added to the current Model.

There is another issue (which is what is even more important). Macroable steals __call and __callStatic from the class inheritance where it was inserted as a trait and do not forward it if not used.

This means that the user should know that Macroable has this behavior and that any class in the inheritance tree does not use either __call or __callStatic.

Anyways, the solution is very simple too.

@shaedrich
Copy link
Contributor

Is the Macroable trait really 100% incompatible with __call() and __callStatic()?

Two alternative solutions:

  1. Maintain a hard-coded list of methods that can't be registered via ::macro() (for this particular class(?))
    • Pro: Relatively easy to implement
    • Contra: Will probably not cover all use cases
  2. Provide a consistent global way of registering magic methods combined for different mechanisms under the hood
    • Pro: Will cover more use cases
    • Contra: Probably a lot has to be changed

Comment on lines +122 to +127
foreach (class_parents(static::class) as $parent) {
if (method_exists($parent, '__call')) {
/** @phpstan-ignore class.noParent, staticMethod.notFound */
return parent::__call($method, $parameters);
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
foreach (class_parents(static::class) as $parent) {
if (method_exists($parent, '__call')) {
/** @phpstan-ignore class.noParent, staticMethod.notFound */
return parent::__call($method, $parameters);
}
}
if(is_callable(['parent', '__call']) {
return parent::__call($method, $parameters);
}

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that would be nicer, but it is deprecated by now.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MrPunyapal

is_callable(['parent', '__call'])

Using parent as a string on a callable is deprecated.

Reference: https://wiki.php.net/rfc/deprecate_partially_supported_callables

But this is fine:

if(is_callable([parent::class, '__call']) {
   return parent::__call($method, $parameters);
}

See this section on the RFC: https://wiki.php.net/rfc/deprecate_partially_supported_callables#backward_incompatible_changes

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks I thought that is_callable is deprecated!
Thanks for clearing 🫡

Comment on lines +89 to +95
foreach (class_parents(static::class) as $parent) {
if (method_exists($parent, '__callStatic')) {
/** @phpstan-ignore class.noParent, staticMethod.notFound */
return parent::__callStatic($method, $parameters);
}
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
foreach (class_parents(static::class) as $parent) {
if (method_exists($parent, '__callStatic')) {
/** @phpstan-ignore class.noParent, staticMethod.notFound */
return parent::__callStatic($method, $parameters);
}
}
if(is_callable(['parent', '__callStatic']) {
return parent::__callStatic($method, $parameters);
}

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that would be nicer, but it is deprecated by now.

@taylorotwell
Copy link
Member

Thanks for your pull request to Laravel!

Unfortunately, I'm going to delay merging this code for now. To preserve our ability to adequately maintain the framework, we need to be very careful regarding the amount of code we include.

If applicable, please consider releasing your code as a package so that the community can still take advantage of your contributions!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants