Skip to content

Fix: internal interfaces should not prevent class/enum early binding#21380

Open
AJenbo wants to merge 1 commit intophp:masterfrom
AJenbo:inter-interfaces
Open

Fix: internal interfaces should not prevent class/enum early binding#21380
AJenbo wants to merge 1 commit intophp:masterfrom
AJenbo:inter-interfaces

Conversation

@AJenbo
Copy link

@AJenbo AJenbo commented Mar 7, 2026

Problem

Implementing an internal interface prevents the compiler from hoisting (early binding) a class or enum, breaking forward references that would otherwise work:

// Works
class B extends A {}
class A {}

// Fatal error: Class "A" not found
class B extends A {}
class A {
    public function __toString(): string { return ''; }
}

// Same problem with explicit Stringable
class B extends A {}
abstract class A implements Stringable {}

// Same problem with other internal interfaces
class B extends A {}
class A implements Countable {
    public function count(): int { return 0; }
}

// Same problem with enums (implicit UnitEnum)
echo Suit::Hearts->name;
enum Suit { case Hearts; }

Previously reported as #7873, #8323, and #19729, all closed as won't fix.

Root cause

The early binding guard in zend_compile_class_decl() checks !ce->num_interfaces and skips hoisting when any interfaces are present:

/* We currently don't early-bind classes that implement interfaces or use traits */
if (!ce->num_interfaces && !ce->num_traits && ...) {

This makes sense for userland interfaces that may not exist yet at compile time, but internal interfaces like Stringable, Countable, ArrayAccess, Iterator, IteratorAggregate, Traversable, UnitEnum, and BackedEnum are registered during engine startup and are always available.

The most common triggers:

  • __toString(): add_stringable_interface() implicitly adds Stringable during compilation
  • Enums: zend_enum_add_interfaces() implicitly adds UnitEnum (and BackedEnum)
  • Any explicit implements of a built-in interface

Fix

Zend/zend_compile.c:

  • New zend_can_early_bind_interfaces() uses an allowlist of known-safe core interfaces rather than allowing all internal interfaces. Some internal interfaces have interface_gets_implemented callbacks that trigger fatal errors or user-observable side effects at compile time (e.g. DateTimeInterface errors for user classes, Throwable errors for non-Exception/Error classes, Serializable triggers E_DEPRECATED via the user error handler). Only interfaces without a callback (Stringable, Countable) or with known-safe callbacks (ArrayAccess, Iterator, IteratorAggregate, Traversable, UnitEnum, BackedEnum) are allowed.
  • zend_inheritance_check_override() moved after interface resolution so #[\Override] works correctly with interface methods.
  • Enums get zend_enum_register_funcs() (registers cases(), from(), tryFrom()) and zend_verify_enum() in the early binding path, matching what zend_do_link_class() does.

Zend/zend_inheritance.c:

  • New zend_early_bind_resolve_internal_interfaces() for the no-parent early binding path. Uses zend_do_implement_interfaces() (same function zend_do_link_class() uses) for correct deduplication of overlapping interface hierarchies (e.g. a class implementing both RecursiveIterator and Iterator).
  • zend_try_early_bind() extended to handle classes that have their own unresolved interfaces when early binding with a parent, combining them with parent interfaces via zend_do_implement_interfaces(). Also builds the traits_and_interfaces array for opcache's inheritance cache functions, which previously received NULL since num_interfaces was always 0 in the early-bind path.

Tests

New test Zend/tests/early_binding_internal_interfaces.phpt covers:

  • Implicit Stringable (from __toString())
  • Explicit implements Stringable
  • Countable, ArrayAccess, IteratorAggregate
  • Multiple internal interfaces combined
  • Abstract parent with Stringable, child with __toString()
  • Both parent and child having __toString()
  • String casting through inheritance
  • Unit enum hoisting with cases()
  • Backed enum hoisting with from() and tryFrom()

@AJenbo AJenbo requested a review from dstogov as a code owner March 7, 2026 19:19
@AJenbo AJenbo marked this pull request as draft March 7, 2026 19:54
@AJenbo AJenbo force-pushed the inter-interfaces branch 2 times, most recently from cb88e84 to 6a0d490 Compare March 7, 2026 20:21
@AJenbo AJenbo marked this pull request as ready for review March 7, 2026 20:22
@AJenbo AJenbo marked this pull request as draft March 7, 2026 20:42
@AJenbo AJenbo force-pushed the inter-interfaces branch from 6a0d490 to 4442ea4 Compare March 7, 2026 21:24
@AJenbo AJenbo marked this pull request as ready for review March 7, 2026 21:42
@AJenbo AJenbo force-pushed the inter-interfaces branch 2 times, most recently from 6998506 to 9c034f3 Compare March 8, 2026 00:02
@AJenbo AJenbo force-pushed the inter-interfaces branch from 9c034f3 to b112e8d Compare March 8, 2026 00:09
…le, etc.)

When a class implements an internal interface, either implicitly
(Stringable via __toString()) or explicitly (Countable, ArrayAccess,
IteratorAggregate, etc.), the early binding guard in
zend_compile_class_decl() rejects it because ce->num_interfaces is
non-zero. This prevents hoisting, causing "Class not found" fatal
errors on forward references like:

    class B extends A {}
    class A { public function __toString(): string { return ''; } }

Since certain internal interfaces are registered during engine startup
and are always available, they should not prevent early binding.

An allowlist of known-safe core interfaces is used rather than allowing
all internal interfaces, because some have interface_gets_implemented
callbacks that can trigger fatal errors or user-observable side effects
at compile time (e.g. DateTimeInterface, Throwable, Serializable).

Changes:
- Add zend_can_early_bind_interfaces() using an allowlist of known-safe
  internal interfaces (Stringable, Countable, ArrayAccess, Iterator,
  IteratorAggregate, Traversable). Interfaces without a callback are
  always safe; those with callbacks are only allowed if explicitly
  listed.
- Add zend_early_bind_resolve_internal_interfaces() to resolve interface
  names via zend_do_implement_interfaces() during early binding, with
  correct handling of overlapping interface hierarchies.
- Extend zend_try_early_bind() to resolve a class's own interfaces when
  early binding with a parent, and build the traits_and_interfaces array
  for opcache's inheritance cache. Also support parent_ce=NULL for
  no-parent classes during opcache's delayed early binding.
- Update opcache's zend_accel_do_delayed_early_binding() to also bind
  no-parent classes with internal interfaces (empty lc_parent_name).
- Handle polyfill patterns in zend_bind_class_in_slot(): when a
  non-toplevel runtime ZEND_DECLARE_CLASS collides with a toplevel
  class that was early-bound at compile time, replace the early-bound
  entry instead of erroring. This supports patterns like:

      if (PHP_VERSION_ID >= 80000) {
          class Foo extends \Bar {}   // non-toplevel, executes
          return;
      }
      class Foo { ... }               // toplevel, early-bound

  Detected via ZEND_ACC_TOP_LEVEL: old class has it, new class doesn't.

Closes phpGH-7873
Closes phpGH-8323
Closes phpGH-19729
@AJenbo AJenbo force-pushed the inter-interfaces branch from b112e8d to 17b0729 Compare March 8, 2026 00:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant