# RFC: __exists(), a magic method for distinguishing "missing" from "set to null"

- Status: Declined
- Authors: Nicolas Grekas nicolasgrekas@php.net
- Date: 2026-05-30
- Version: 0.2
- Original: https://wiki.php.net/rfc/exists-magic-method
- Page: https://externals.io/rfc/exists-magic-method
- More for agents: search with https://externals.io/search.md?q={query} · RFCs at https://externals.io/rfc.md · JSON API at https://externals.io/api · documentation at https://externals.io/llms.txt

## Votes

### Add __exists() magic method? (closed)

Yes: 2 · No: 13 · Abstain: 7

Voters: alcaeus (Abstain), crell (Abstain), daniels (Yes), derick (No), dragoonis (Abstain), duncan3dc (Abstain), galvao (No), girgias (No), heiglandreas (No), jordikroon (No), kalle (No), kguest (No), mauricio (Yes), ocramius (No), ramsey (Abstain), rasmus (No), shivam (No), theodorejb (Abstain), thorstenr (No), timwolla (No), weierophinney (No), weilindu (Abstain)

## Discussions on the mailing list

- [\[RFC\] __exists(), a magic method for distinguishing "missing" from "set to null"](https://externals.io/message/130725.md) — started on 2026-05-01, 13 messages
- [\[VOTE\] __exists(), a magic method for distinguishing "missing" from "set to nul](https://externals.io/message/131243.md) — started on 2026-06-14, 2 messages

---

## 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](https://github.com/php/php-src/issues/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:

```php
$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](https://externals.io/rfc/isset_ternary) 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:

```php
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:

```php
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`:

1. Call `__exists($prop)`.
2. If it returns `false`, `??` evaluates `$y`. `__get()` is **not** called.
3. If it returns `true` and the property now exists on the object (typically because `__exists()` just wrote to it), its value is returned directly, with `??` applying its usual `null` check on top. `__get()` is **not** called.
4. 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)`):

1. Call `__exists($prop)`.
2. If it returns `false`, `isset()` returns `false` (and `empty()` returns `true`). `__get()` is **not** called.
3. If it returns `true` and the property now exists on the object, the standard `isset()`/`empty()` rules are applied to its value (i.e. `isset()` is `false` when the value is `null`). `__get()` is **not** called.
4. 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:

```php
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()

```php
#[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`:

```php
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-&gt;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

```php
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

```php
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

```php
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](https://github.com/php/php-src/issues/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 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. Universal BC break.
- **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.

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:

1. **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.
2. **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 every `isset()`/`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 per `isset()` on dual-defined classes, but requires userland to keep both methods 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+.” 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 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:

```php
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`:

1. Call `__isset($prop)`.
2. If false, `??` evaluates `$y`.
3. 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 usual `null` check on top.

For `isset($obj->prop)`:

1. Call `__isset($prop)`. Its return (cast to `bool`) is the result; `__get()` is not called, even when the value is in fact `null`.

For `empty($obj->prop)`:

1. Call `__isset($prop)`.
2. If false, `empty()` returns `true`.
3. If true and the property now exists on the object, `empty()` returns whether its value is falsy. Otherwise `__get($prop)` is called (when defined) and `empty()` applies the same check to its return value. With no `__get()`, `empty()` returns `true`.

### With only \_\_get() defined (no \_\_isset())

- `$obj->prop ?? $y`: `__get($prop)` is called and `??` applies its usual `null` check.
- `isset($obj->prop)`: always returns `false`. `__get()` is not called.
- `empty($obj->prop)`: always returns `true`. `__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 2026-06-14 and closes 2026-06-28.

Requires 2/3 majority.

**Vote: Add \_\_exists() magic method?** — Yes: 2 · No: 13 · Abstain: 7 (closed)

## 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](https://github.com/php/php-src/issues/12695) (sequencing fix landed separately in PHP 8.6; the broader ambiguity is addressed by this RFC)
- [RFC: Null Coalesce Operator](https://externals.io/rfc/isset_ternary)
- [RFC: Lazy Objects](https://externals.io/rfc/lazy-objects) (PHP 8.4): addresses an adjacent but distinct subset of the lazy-init use case
- [RFC: Property Hooks](https://externals.io/rfc/property-hooks) (PHP 8.4): addresses computed/derived properties for declared shapes
