====== PHP RFC: __exists(), a magic method for distinguishing "missing" from "set to null" ====== * Version: 0.1 * Date: 2026-05-01 * Author: Nicolas Grekas * Status: Under Discussion * First Published at: https://wiki.php.net/rfc/exists-magic-method ===== Introduction ===== PHP's __isset() magic method has a structural ambiguity: the language constructs that trigger it (''isset()'', ''??'', ''empty()'') treat ''null'' as "not set", forcing __isset() to answer the narrow question "set and not null" rather than the broader question "exists". This makes __isset() unable to model an underlying store that distinguishes ''null'' from "missing" (a distinction arrays expose through ''array_key_exists()'') and it breaks the documented equivalence ''isset($x) ? $x : $y'' ''<=>'' ''$x ?? $y'' for magic properties. This RFC adds a new opt-in magic method, __exists(), that answers the existence question independently from value. It restores ''isset() <=> ??'' equivalence on magic properties and gives userland code a way to tell ''null'' apart from "missing" through a direct method call. This work started from [[https://github.com/php/php-src/issues/12695|GH-12695]], a small engine inconsistency in the sequencing of __isset() and __get() under the ''??'' operator. That inconsistency turned out to be a visible symptom of the same underlying ambiguity, which is the problem this RFC sets out to address. ===== Problem Statement ===== ==== "Set to null" is indistinguishable from "missing" ==== For arrays, the language gives users two distinct primitives: $a = ['x' => null]; isset($a['x']); // false array_key_exists('x', $a); // true For objects there is no equivalent. __isset() is forced into the ''isset()'' contract (return ''false'' for null), so a userland store that uses ''null'' as a meaningful value (caches, JSON-mapped objects, RPC stubs, configuration objects) cannot expose its own existence semantics through a magic method at all. This is the central gap: a method whose return type is ''bool'' can carry only one bit of information, while modelling these stores requires two ("exists?" and "non-null?"). The single available bit is consumed by ''isset()''s null-folding rule, leaving "exists?" unanswerable. ==== isset() versus ?? are not equivalent on magic properties ==== The [[https://wiki.php.net/rfc/isset_ternary|''??'' RFC]] documents that ''$x ?? $y'' is intended as a shorthand for ''isset($x) ? $x : $y''. This holds for normal properties and for arrays, but **not** for magic properties: class B { public function __get($n) { return null; } public function __isset($n) { return true; } } $b = new B; var_dump($b->any ?? 5); // int(5): ?? checks __get's return for null var_dump(isset($b->any) ? $b->any : 5); // NULL: isset() trusts __isset, returns null from __get var_dump(isset($b->any)); // bool(true) does not consult __get at all The three forms disagree because ''isset()'' on a magic property today **only** calls __isset() and **does not** verify the value is non-null via __get(). The ''??'' operator does. There is no way to align these without either calling __get() twice (slow, breaks BC) or returning a possibly-null result from ''??'' (breaks documentation and static analyzers). The ambiguity in __isset() is the reason the engine had to choose, and either choice contradicts a different reasonable expectation. ==== Origin: GH-12695, the symptom that surfaced the root cause ==== The investigation that produced this RFC started from a smaller, more visible defect. [[https://github.com/php/php-src/issues/12695|GH-12695]] (open since PHP 7.0) reports that under ''??'', the engine calls both __isset() and __get() even when __isset() has just materialised the property: #[AllowDynamicProperties] class A { public function __get($n) { echo "__get\n"; return $this->$n; } public function __isset($n) { echo "__isset\n"; $this->$n = 123; return true; } } $a = new A; echo $a->foo ?? 234; // prints: __isset / __get / 123 The redundant __get() call is mostly cosmetic, but it bites lazy-proxy patterns where the second call traverses a parent magic getter that does not know about the lazy property and either re-fetches the same value or throws. The GH-12695 thread evaluated several engine-only fixes (re-checking the property table after __isset(), skipping __get() when __isset() returned ''true'' under ''??'', etc.) and rejected each on BC grounds. The deeper reason is the one above: __isset() returns a single bit and the engine cannot tell apart "the userland code just produced the value" from "the userland code returned a boolean answer to a question". The fix is therefore not a sequencing tweak but an opt-in mechanism in which the userland contract is explicit, which is what this RFC introduces. ===== Proposal ===== This RFC adds a new magic method: public function __exists(string $name): bool; It answers "does this property logically exist?", independent of whether its value is ''null''. ==== Signature ==== * Must be **public** and **non-static**. * Single parameter, following the regular variance rules: it may be omitted (mirrors __isset() BC behaviour) or declared as ''string''. Subclasses may widen the parameter type contravariantly (e.g. ''string|int''), as with any other method. * Return type **must be declared** and must be ''bool'' (or a covariant subtype such as ''true'' or ''false''). Unlike __isset(), which permits an undeclared return type for backwards compatibility with code that predates return type declarations, __exists() is a new method and the BC carve-out does not apply to it. * Allowed in interfaces with no special engine treatment (mirrors __isset() / __get()). * Disallowed on enums (mirrors __isset()). ==== Engine integration ==== When a class defines __exists(), the engine consults it instead of __isset() for the following language constructs: * ''$obj->prop ?? fallback'' * ''isset($obj->prop)'' * ''empty($obj->prop)'' * Nullsafe + ''??'' (''$obj?->prop ?? fallback'') __isset() is **not** called by the engine when __exists() is defined. ''property_exists()'' is **not** affected by __exists() (mirrors today's behavior for __isset()): ''property_exists()'' continues to inspect the declared class shape and instance properties only, never magic. ==== Sequencing ==== The rules below apply only when a class defines __exists(). Classes that do not define it observe the engine's existing behaviour unchanged; that behaviour is laid out in full under [[#existing_sequencing|Existing sequencing]] near the end of this RFC. For ''$obj->prop ?? $y'': - Call __exists($prop). - If it returns ''false'', ''??'' evaluates ''$y''. __get() is **not** called. - If it returns ''true'', the engine re-checks the property table. If the property now exists physically (typically because __exists() materialised it), its value is the result, and ''??'' applies its usual ''null'' check on top. __get() is **not** called. - Otherwise, __get($prop) is called and its return is the result, with ''??'' applying its usual ''null'' check on top. For ''isset($obj->prop)'' (and equivalently ''empty($obj->prop)''): - Call __exists($prop). - If it returns ''false'', ''isset()'' returns ''false'' (and ''empty()'' returns ''true''). __get() is **not** called. - If it returns ''true'', the engine re-checks the property table. If the property now exists physically, the standard ''isset()''/''empty()'' rules are applied to its value (i.e. ''isset()'' is ''false'' when the value is ''null''). __get() is **not** called. - Otherwise, __get($prop) is called and the same rules are applied to its return value. This restores the documented equivalence ''isset($x) ? $x : $y'' ''<=>'' ''$x ?? $y'' for any class that defines __exists(): both go through the same fetch-then-null-check pipeline. ==== Re-checking the property table ==== __exists() is allowed to mutate object state. This is the load-bearing capability for lazy materialisation. After __exists() returns ''true'', the engine looks up the property again before falling through to __get(): - For declared properties: re-read ''OBJ_PROP'' at the same offset. - For dynamic properties: re-search ''zobj->properties''. If the property is now present, its value is returned directly. This skips a redundant __get() call in the materialisation path and is the direct fix for GH-12695. ==== Interaction with __isset() ==== A class can declare both __isset() and __exists(). This is intended as a forward-compatibility shim: a library can ship __exists() on PHP 8.6+ while keeping __isset() for older runtimes. On PHP 8.6+, only __exists() is consulted by the engine. On older runtimes, only __isset() is. No diagnostic is emitted for declaring both: that would defeat the shim. ==== Direct callability ==== __exists() is a regular method and can be called directly: class C { private array $store = ['nullProp' => null]; public function __exists(string $n): bool { return array_key_exists($n, $this->store); } public function __get(string $n): mixed { return $this->store[$n] ?? null; } } $c = new C; var_dump($c->__exists('nullProp')); // bool(true): exists, even though value is null var_dump(isset($c->nullProp)); // bool(false): isset() still folds null into "not set" This is the disambiguation pattern. Code that needs to distinguish "set to null" from "missing" calls __exists() directly, the same way it would call ''array_key_exists()'' for arrays. ==== Inheritance ==== Standard. __exists() is inherited like any other method. Classes that override their parent's __isset() may instead introduce __exists(); this is the recommended migration path. When a child defines __exists() and a parent defines __isset(), the child's __exists() takes effect for the whole magic check (including for properties whose semantics live in the parent's __get()). ==== Recursion guard ==== __exists() shares the same recursion guard slot as __isset(). Recursive ''isset()''/''??'' calls on the same property from inside __exists() short-circuit to "not set" (as they already do for __isset()). This both prevents infinite recursion and avoids growing the per-object guard state. ==== Uninitialized typed properties ==== __exists() is **skipped** for never-initialized typed properties, exactly like __isset(). The opt-in for engaging magic methods on a declared property is the existing one: call ''unset()'' on it (typically from the constructor), which is the same pattern lazy proxies have used with __isset() for years. This keeps a single, well-known mental model for "when do magic methods apply to a typed property" across both methods, so migration to __exists() does not require revisiting that question. ==== Examples ==== === Materialisation in __exists() avoids a redundant __get() (corollary, GH-12695) === #[AllowDynamicProperties] class A { public function __exists(string $n): bool { $this->$n = 123; return true; } public function __get(string $n): mixed { throw new Exception('unreachable when __exists materialised the property'); } } $a = new A; echo $a->foo ?? 234; // 123 (__get is not called) === Recommended floor for classes with __get ==== A class that has only __get() today sees ''isset()'' always return ''false'' and ''empty()'' always return ''true'' on its magic properties (the engine never consults __get() from those constructs). Adding __exists() fixes both for free, even when it always returns ''true'': class C { public function __exists(string $n): bool { return true; } public function __get(string $n): mixed { /* return value, or null if absent */ } } ^ expression ^ __get() only ^ __get() + always-true __exists() ^ | ''isset($c->present)'' | ''false'' | ''true'' | | ''empty($c->present)'' | ''true'' | ''false'' | | ''$c->present ?? 'fb' '' | unchanged | unchanged | Caveat: this default assumes __get() returns ''null'' for unknown names without throwing. If __get() throws on unknown names, __exists() must instead mirror the underlying store (see **Distinguishing null from missing** below) so that ''isset()'' does not propagate the exception. === Distinguishing null from missing === class JsonRecord { public function __construct(private array $data) {} public function __exists(string $name): bool { return array_key_exists($name, $this->data); } public function __get(string $name): mixed { if (!array_key_exists($name, $this->data)) { throw new RuntimeException("Field $name does not exist in record"); } return $this->data[$name]; } } $r = new JsonRecord(['nullable' => null]); $r->__exists('nullable'); // true $r->__exists('missing'); // false isset($r->nullable); // false (isset()'s null-folding) isset($r->missing); // false $r->nullable; // null (no exception, exists) $r->missing; // throws (does not exist) === Lazy proxies === class LazyProxy extends Real { private bool $initialized = false; public function __exists(string $n): bool { if (!$this->initialized) { $this->initialize(); } return parent::__exists($n); // or property_exists / array_key_exists / etc. } public function __get(string $n): mixed { if (!$this->initialized) { $this->initialize(); } return parent::__get($n); } private function initialize(): void { /* ... */ } } === Forward-compatibility shim across PHP versions === class C { // Used by PHP 8.5 and earlier: public function __isset(string $n): bool { return $this->__exists($n) && null !== $this->__get($n); } // Used by PHP 8.6 and later: public function __exists(string $n): bool { return /* ... */; } public function __get(string $n): mixed { /* ... */ } } ===== Rationale ===== ==== Why a new method instead of fixing __isset() ==== The natural reaction is "if this is broken, why not just fix the engine?". Several engine-only fixes were proposed in the [[https://github.com/php/php-src/issues/12695|GH-12695]] thread, and each was rejected. Each one changes observable behaviour for code that already works today: * **Skip ''%%__get()%%'' under ''??'' when ''%%__isset()%%'' returned ''true''.** Today, the value ''??'' produces is the value __get() returns. Skipping __get() would change that to "whatever happens to be in the property table after __isset() ran", a different value for any class whose __get() does more than read a stored property (logging proxies, decoration, cache lookups, lazy hydration that lives in __get() rather than in __isset()). * **Re-check the property table after ''%%__isset()%%'' and skip ''%%__get()%%'' only if the slot now holds a value.** This was tried in [[https://github.com/php/php-src/pull/12698|iluuu1994's patch]]. It does not work for declared-but-unset typed properties (the slot looks "uninitialized" both before and after) and has soundness concerns: if __isset() frees the object via internal reference handling, the post-call lookup uses a freed pointer. * **Make ''isset()'' call ''%%__get()%%'' to verify non-null on magic properties.** Doubles the userland calls for every ''isset()'' on a magic property. Any class whose __get() has side effects (logging, cache warm-up, counters, lazy init) sees those side effects run at every ''isset()'', where they don't run at all today. * **Make ''??'' trust ''%%__isset()%%'' and not check the ''%%__get()%%'' result for null.** ''??'' would now be able to return ''null'', contradicting the documented "set and not null" contract and breaking static analyzers that infer "non-null" from the operator. The common thread is that __isset() returns a single bit and the engine cannot tell apart "the userland code just produced the value" from "the userland code returned a boolean answer to a question". The missing information cannot be recovered after the fact; it has to be available in the contract. __exists() is exactly that contract: its name and signature commit userland code to "I am answering the existence question, and if I produced a value you can read it from the property table". Classes that don't opt in are unaffected; classes that do gain the cleaner sequencing automatically. This is the smallest change that resolves the ambiguity without rewriting any existing behaviour. ==== Why call __get() from isset() when __exists() is defined ==== Two reasons: - **Equivalence with ''??''.** The documented contract ''isset($x) ? $x : $y'' ''<=>'' ''$x ?? $y'' is currently broken on magic properties; restoring it on classes that opt in is the cleanest available fix. - **Standard isset() semantics.** Users learn ''isset()'' as "set and not null". Magic properties that opt into the new mechanism should not silently break that learning. The cost is one extra __get() call per ''isset()'' on a magic property. This is opt-in: classes that care about preserving the old single-call behavior simply do not declare __exists(). ==== Why allow __exists() to mutate state ==== Lazy materialisation is the load-bearing use case. Disallowing mutation would force lazy proxies back into the existing two-call pattern that this RFC is trying to remove. The re-check of the property table after the call ensures that mutation is observed, removing the redundant __get() call. ==== Why __exists() supersedes __isset() rather than augmenting it or splitting dispatch ==== A class that defines __exists() has, in effect, opted out of __isset(). Two alternative dispatch designs were considered and rejected: * **Calling both** __isset() and __exists() for every ''isset()''/''empty()''/''??'' check. Doubles the userland call cost with subtle ordering rules and provides nothing the single-method dispatch cannot. * **Splitting dispatch** when both are defined: route ''isset()''/''empty()'' to __isset() (the cheaper path, since __isset() already returns the "set and not null" combined answer) and route ''??'' to __exists(). This would save one __get() call per ''isset()'' on dual-defined classes, but it requires userland to keep __isset() and __exists() semantically consistent: any drift re-introduces the ''isset()'' vs ''??'' disagreement this RFC is fixing. The simpler rule wins: "if __exists() is defined, __isset() is dormant on PHP 8.6+." It is easy to teach, and the engine guarantees ''isset() <=> ??'' equivalence regardless of how userland implements its methods. Declaring both remains valid as a forward-compatibility shim pattern. Note that ''??'' itself incurs no extra cost on classes that define __exists(): its call count matches today's __isset() path (one existence check, then one __get() only when the property exists). The extra __get() call only appears for standalone ''isset()''/''empty()'' and for the ''isset($x) ? $x : $y'' form. Code that prefers ''$x ?? $y'' (the documented equivalent) gets the optimal call count automatically. ==== Why not deprecate __isset() now ==== __isset() has shipped since PHP 5 and is widely used. A deprecation in the same release that introduces __exists() would force every existing class to migrate immediately, which is precisely the BC cost this RFC is structured to avoid. Eventual deprecation is a plausible long-term direction once __exists() has seen meaningful adoption (a future major version, not this RFC). The present RFC takes no position on whether that will actually happen; it simply leaves the door open. Both methods coexisting indefinitely is also a valid endgame. That said, the PHP manual and adjacent reference documentation should recommend __exists() as the preferred choice for new code. This steers adoption without imposing the immediate migration cost of a formal deprecation. ==== Why not extend property_exists() ==== ''property_exists()'' is documented as a reflection-style operator over the **declared class shape** plus instance dynamic properties; it explicitly does not invoke magic methods. Hooking __exists() into it would change that contract and would also create a path for ''property_exists()'' to throw or run arbitrary code, contradicting decades of expectations. Users who want "magic-aware existence" should call ''%%$obj->__exists($name)%%'' directly. ==== Naming ==== ''%%__exists%%'' was chosen over ''%%__has%%'', ''%%__propertyExists%%'' or ''%%__hasProperty%%''. It mirrors ''ArrayAccess::offsetExists()'' (closest existing analogue) and is short. ===== Backward Incompatible Changes ===== None. __exists() is opt-in: classes that do not define it observe identical behavior. The ''%%__%%''-prefix namespace is reserved by PHP for language use, so any pre-existing ''%%__exists%%'' method is already operating in reserved space. In practice the name is self-describing enough that a colliding method would already mean "does this exist?", so the new dispatch lines up with the existing intent. As with __isset() in PHP 5, the engine validates the signature at class compile time and emits a fatal error if it is incompatible. ===== Proposed PHP Version(s) ===== Next minor version (PHP 8.6 or later). ===== RFC Impact ===== ==== To SAPIs ==== None. ==== To Existing Extensions ==== * ''opcache'': adds the new ''%%ce->__exists%%'' slot to the persistence path (''zend_persist.c'', ''zend_file_cache.c''). Required because magic-method pointers are re-resolved across persistence; a missed slot results in an invalid pointer and a crash on the first call. Mechanical change. * ''reflection'': ''ReflectionProperty::isReadable()'' consults __exists() (preferred) or __isset() when __get() is defined. This matches user intent for "is this readable" on classes that have migrated to __exists(). No other extension is affected. ==== To Ecosystem ==== * Lazy-object libraries gain a clean migration target. * Static analyzers (PHPStan, Psalm, Phan) need to learn the new method name and its semantics. * IDEs need autocompletion + signature awareness. * Frameworks that document or wrap __isset() patterns can update guidance. ===== Rejected Features ===== ==== Three-state enum return from __isset() ==== During discussion, an alternative was raised: rather than adding __exists(), extend __isset() to optionally return a three-state enum (e.g. ''Isset::Set'', ''Isset::Null'', ''Isset::Unset'') so that a single magic method carries both bits of information. This was rejected for two main reasons: * **No forward-compatibility path.** PHP 8.5 and earlier reject any __isset() return type other than ''bool'' or a covariant subtype. Declaring public function __isset(string $n): Isset or public function __isset(string $n): bool|Isset produces a fatal error at class compile time on those runtimes (''ClassName::%%__isset()%%: Return type must be bool when declared''). The only workarounds are dropping the return type entirely (a regression for typed codebases) or shipping two version-conditional class declarations (which defeats the single-source-of-truth premise the proposal rests on). By contrast, choosing a new magic method is a deliberate FC design choice: __exists() is a regular method on PHP 8.5 and earlier and elevates to magic on PHP 8.6+. The same source file runs everywhere with no declaration changes, and the convention is **usable on older PHP today**: callers can probe method_exists($obj, '__exists') and dispatch through it as a disambiguation primitive, so libraries can adopt the pattern now and deliver value before PHP 8.6 is universally available. The enum proposal has no such bridge. * **No contradiction to mediate.** The enum proposal could motivate itself as eliminating edge cases where __isset() and __exists() could disagree about a property's state. But in this RFC, the engine never consults both: when __exists() is defined, __isset() is fully dormant. The disagreement is structurally impossible. Direct-call ergonomics also favour a separate method: $obj->__exists($n) returns a plain ''bool'', making it the natural disambiguation primitive (mirroring ''array_key_exists()''), whereas $obj->__isset($n) === Isset::Null would be wordier and leak the enum into every consumer that asks "exists?". ===== Future Scope ===== ==== Property hooks ==== PHP 8.4 property hooks raise an open question this RFC takes no position on: should there be an ''exists'' hook alongside ''get''/''set''? One view: a hooked property already "exists" by virtue of having a hook, and ''isset()''/''??'' on it should simply use the value the ''get'' hook returns (which is what happens today). On that view, there is no gap to close. Another view: a ''get'' hook over a backing store where the key may legitimately be missing has the same ambiguity this RFC fixes for magic properties. A symmetric ''exists'' hook would let the hooked property model that distinction: public ?string $name { get => $this->store['name'] ?? null; exists => array_key_exists('name', $this->store); } Either way, hooks are a separate surface (per-property, declared-shape) with their own questions (interaction with the ''$field'' backing-property visibility, virtual hooks, asymmetric visibility, inheritance), and any extension belongs in its own RFC. This proposal is scoped to magic properties. ==== Possible future deprecation of __isset() ==== Discussed in the Rationale section: it is a plausible long-term direction but uncommitted, and deliberately left for a future RFC. ===== Existing sequencing ===== For completeness and to make the migration story concrete, here is the engine's behaviour today when a class does not define __exists(). Adding __exists() to a class swaps the rules under [[#sequencing|Sequencing]] above into use; classes that do not opt in observe the rules below unchanged. ==== With __isset() defined (with or without __get()) ==== For ''$obj->prop ?? $y'': - Call __isset($prop). - If it returns ''false'', ''??'' evaluates ''$y''. __get() is **not** called. - If it returns ''true'', __get($prop) is called (when defined) and ''??'' applies its usual ''null'' check on top of the return value. __get() is called even when __isset() just wrote into the property table; this is the GH-12695 sequencing issue described in the Problem Statement. For ''isset($obj->prop)'': - Call __isset($prop). Its return (cast to ''bool'') is the result. __get() is **not** called, even when the underlying value is in fact ''null''. This is the divergence from ''??'' described in the Problem Statement: ''isset()'' trusts the bool, ''??'' applies a ''null'' check on top of __get(). For ''empty($obj->prop)'': - Call __isset($prop). - If it returns ''false'', ''empty()'' returns ''true''. __get() is **not** called. - If it returns ''true'', __get($prop) is called (when defined) and ''empty()'' returns whether its return value is falsy. When __get() is not defined, ''empty()'' returns ''true''. ==== With only __get() defined (no __isset()) ==== * ''$obj->prop ?? $y'': __get($prop) is called and ''??'' applies its usual ''null'' check on top. * ''isset($obj->prop)'': always returns ''false''. __get() is **not** called. * ''empty($obj->prop)'': always returns ''true''. __get() is **not** called. This is one source of the "broken `isset()` on magic properties" reputation: a class with only __get() sees ''isset()'' return ''false'' even when __get() would happily produce a non-null value. Defining __exists() (even one that always returns ''true'') fixes this without forcing the class to also implement __isset(). ==== With neither __isset() nor __exists() (and no __get()) ==== The standard "undefined property" path applies: ''$obj->prop ?? $y'' returns ''$y'' silently (after a notice on direct read), ''isset($obj->prop)'' returns ''false'', ''empty($obj->prop)'' returns ''true''. ===== Proposed Voting Choices ===== Voting opens YYYY-MM-DD and closes YYYY-MM-DD. Requires 2/3 majority. * Yes * No * Abstain ===== Patches and Tests ===== Implementation: https://github.com/nicolas-grekas/php-src/pull/2 Tests live under ''Zend/tests/magic_methods/exists/'' and cover: * ''??'' with __exists() returning true / false * ''??'' when __exists() materialises the property (no __get() call) * ''isset()'' / ''empty()'' equivalence with ''??'' * Both __isset() and __exists() defined: __exists() wins * Inheritance: child __exists() overrides parent __isset() * Recursion guard against re-entering __exists() on the same property * Class without __get() * Signature validation (param type, return type, non-static) * Typed never-initialized properties (skipped, parity with __isset()); ''unset()'' opt-in * Direct __exists() call disambiguates "set to null" from "missing" * Characterization of pre-RFC __isset() behavior remains unchanged when __exists() is absent ===== References ===== * [[https://github.com/php/php-src/issues/12695|GH-12695: Wrong magic methods sequence with ?? operator]] * [[https://wiki.php.net/rfc/isset_ternary|RFC: Null Coalesce Operator]] * [[https://wiki.php.net/rfc/lazy-objects|RFC: Lazy Objects]] (PHP 8.4): addresses an adjacent but distinct subset of the lazy-init use case * [[https://wiki.php.net/rfc/property-hooks|RFC: Property Hooks]] (PHP 8.4): addresses computed/derived properties for declared shapes