====== PHP RFC: __exists(), a magic method for distinguishing "missing" from "set to null" ====== * Version: 0.1 * Date: 2026-04-26 * Author: Nicolas Grekas * Status: Under Discussion * First Published at: https://wiki.php.net/rfc/exists ===== 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 ==== This RFC affects what the engine **returns** for a magic property fetch in ''BP_VAR_IS'' mode (''??'', ''isset()'', ''empty()''). The language constructs themselves keep their usual semantics on top of that returned value: ''??'' falls back when the value is ''null'', ''isset()'' returns ''false'' when the value is ''null'', ''empty()'' returns ''true'' when the value is falsy. The list below describes what the engine produces, **not** what the surrounding construct does with it. For ''$obj->prop'' in ''BP_VAR_IS'' mode (the read used by ''??''): - Call __exists($prop). - If it returns ''false'', the fetch produces "uninitialized" (treated as ''null'' by ''??'', so ''y'' is evaluated). __get() is **not** called. - If it returns ''true'', re-check the property table. If the property now exists physically (typically because __exists() materialised it), the fetch produces that property's value directly. __get() is **not** called. The value may itself be ''null'', in which case ''??'' still falls back, exactly as it does for a regular property whose value is ''null''. - Otherwise, call __get($prop) and the fetch produces its return value (again, possibly ''null'', handled by ''??'' as usual). 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'', re-check the property table; if the property exists physically, apply the standard ''isset()''/''empty()'' rules to its value (i.e. ''isset()'' is ''false'' if the value is ''null''). - Otherwise, call __get($prop) and apply the same rules 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): legacy 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 (legacy 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 legacy __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. A future RFC may revisit this once __exists() has seen meaningful adoption. ==== 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. ===== 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/php/php-src/pull/TBD 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 legacy __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