PHP RFC: __exists(), a magic method for distinguishing "missing" from "set to null"
- Version: 0.2
- Date: 2026-05-30
- Author: Nicolas Grekas nicolasgrekas@php.net
- Status: Under Discussion
- First Published at: https://wiki.php.net/rfc/exists-magic-method
Introduction
PHP's __isset() magic method has a structural ambiguity: it returns one bit but is asked two
questions (“does it exist?” and “is it non-null?”). The language constructs that trigger it
(isset(), ??, empty()) treat null as “not set”, forcing __isset() to answer the
narrow “set and not null” rather than the broader “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.
Magic accessors remain load-bearing in widely-used PHP: Laravel's Eloquent ORM is built on them, as is any code dealing with property names that are not known at class declaration. Native lazy objects and property hooks (PHP 8.4) cover declared shapes, not runtime-discovered ones. Fixing the broken documented semantics on magic properties is therefore a current concern, not a legacy cleanup.
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.
A narrower engine-side fix for the redundant __get() call under ?? and empty()
(tracked in GH-12695) landed separately in PHP 8.6
and is referenced in the Rationale section. This RFC addresses what that fix cannot reach by
design: the structural ambiguity above.
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 ''??'' 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.
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 asstring. 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 astrueorfalse). 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 ?? fallbackisset($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 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
trueand the property now exists on the object (typically because__exists()just wrote to it), its value is returned directly, with??applying its usualnullcheck on top.__get()is not called. - Otherwise,
__get($prop)is called and its return is the result, with??applying its usualnullcheck on top.
For isset($obj->prop) (and equivalently empty($obj->prop)):
- Call
__exists($prop). - If it returns
false,isset()returnsfalse(andempty()returnstrue).__get()is not called. - If it returns
trueand the property now exists on the object, the standardisset()/empty()rules are applied to its value (i.e.isset()isfalsewhen the value isnull).__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.
__exists() is allowed to mutate object state; the re-check above ensures any mutation is
observed without an extra __get() call. This matches the behaviour PHP 8.6 already gives
__isset() (the GH-12695 fix).
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 to disambiguate “set to null”
from “missing”, the same way array_key_exists() is called for arrays:
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"
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() uses the same recursion guard as __isset(): a recursive isset()/??
call on the same property from inside __exists() short-circuits to “not set”, preventing
infinite recursion.
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() behaves the same as in __isset()
#[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 smallest engine-side fix in this space, re-checking whether the property exists after
__isset() to skip a redundant __get() under ?? and empty(), already landed
in PHP 8.6 independently
of this RFC. It resolves the sequencing issue tracked in
GH-12695.
What the engine fix cannot reach is the structural ambiguity at the heart of this RFC:
__isset() returns one bit but is being asked two questions. Two broader fixes were discussed
in the GH-12695 thread and rejected for concrete behaviour breaks:
- Make
isset()call__get()to verify non-null on magic properties. Doubles the userland calls for everyisset()on a magic property. Any class whose__get()has side effects (logging, cache warm-up, counters, lazy init) sees those side effects run at everyisset(), where they don't run at all today. Universal BC break. - Make
??trust__isset()and not check the__get()result for null.??would now be able to returnnull, contradicting the documented “set and not null” contract and breaking static analyzers that infer “non-null” from the operator.
Both alternatives are one-size changes to constructs that already-existing classes depend on. The
opt-in path here lets classes that need the correct semantics commit to a new contract
(__exists() plus the engine's adjusted dispatch) without forcing the entire ecosystem through
the same migration. Classes that don't opt in are unaffected; classes that do gain the cleaner
sequencing automatically.
Why call __get() from isset() when __exists() is defined
Two reasons:
- Equivalence with
??. The documented contractisset($x) ? $x : $y<=>$x ?? $yis 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 __exists() supersedes __isset() rather than augmenting it or splitting dispatch
Two alternative dispatch designs were considered and rejected:
- Calling both
__isset()and__exists()for everyisset()/empty()/??. Doubles userland calls with subtle ordering rules and provides nothing the single-method dispatch cannot. - Splitting dispatch when both are defined: route
isset()/empty()to__isset()and??to__exists(). Saves one__get()call perisset()on dual-defined classes, but requires userland to keep both methods consistent; any drift re-introduces theisset()vs??disagreement this RFC is fixing.
The simpler rule wins: “if __exists() is defined, __isset() is dormant on PHP 8.6+.”
Declaring both remains valid as a forward-compatibility shim pattern.
?? itself incurs no extra cost compared to today's __isset() path. The extra
__get() call only appears for standalone isset()/empty() and for the
isset($x) ? $x : $y form; code that prefers the documented-equivalent $x ?? $y 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 newce->__existsslot 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 thanboolor a covariant subtype. Declaringpublic function __isset(string $n): Issetorpublic function __isset(string $n): bool|Issetproduces 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 probemethod_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.
Warning for __get() without __isset() or __exists()
A class with only __get() defined sees isset() always return false and empty()
always return true on its magic properties, which is a known footgun. An adjacent diagnostic
(warning, possibly graduating to a deprecation later) could nudge users to declare one of
__isset() or __exists(). Deferred to a follow-up RFC: it has its own impact analysis
to do (Eloquent and similar base classes may pattern-match the rule) and is orthogonal to this
proposal.
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, here is the engine's behaviour today when a class does not define
__exists(). The property-table re-check after __isset() is the engine fix that landed
in PHP 8.6 for GH-12695.
With __isset() defined (with or without __get())
For $obj->prop ?? $y:
- Call
__isset($prop). - If false,
??evaluates$y. - If true and the property now exists on the object (typically because
__isset()just wrote to it), its value is the result. Otherwise__get($prop)is called and??applies its usualnullcheck on top.
For isset($obj->prop):
- Call
__isset($prop). Its return (cast tobool) is the result;__get()is not called, even when the value is in factnull.
For empty($obj->prop):
- Call
__isset($prop). - If false,
empty()returnstrue. - If true and the property now exists on the object,
empty()returns whether its value is falsy. Otherwise__get($prop)is called (when defined) andempty()applies the same check to its return value. With no__get(),empty()returnstrue.
With only __get() defined (no __isset())
$obj->prop ?? $y:__get($prop)is called and??applies its usualnullcheck.isset($obj->prop): always returnsfalse.__get()is not called.empty($obj->prop): always returnstrue.__get()is not called.
This is the “broken isset() on magic properties” path that the Recommended floor for classes with __get example above fixes.
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.
Patches and Tests
Implementation: https://github.com/php/php-src/pull/22240
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
- GH-12695: Wrong magic methods sequence with ?? operator (sequencing fix landed separately in PHP 8.6; the broader ambiguity is addressed by this RFC)
- RFC: Lazy Objects (PHP 8.4): addresses an adjacent but distinct subset of the lazy-init use case
- RFC: Property Hooks (PHP 8.4): addresses computed/derived properties for declared shapes