Skip to content

Make model isset() checks aware of extension properties - #271

Draft
wpjscc wants to merge 1 commit into
wintercms:developfrom
wpjscc:fix/1536-model-isset-extension-aware
Draft

wpjscc wants to merge 1 commit into
wintercms:developfrom
wpjscc:fix/1536-model-isset-extension-aware

Conversation

@wpjscc

@wpjscc wpjscc commented Oct 27, 2026

Copy link
Copy Markdown

Summary

Makes isset(), empty(), ?? and Twig property lookups on models see the same properties __get() sees.

Database\Model::__get() resolves through extendableGet() — dynamic properties, then behavior properties, then the parent getter — but Database\Model::__isset() only called getAttribute(). Halcyon\Model::__isset() had the same asymmetry. Because both classes define __isset(), PHP routes ??, empty() and Twig's is defined / default() / property resolution through it, so a property that reads back fine was reported as not set.

Reproduction (before)

System\Models\EventLog::extend(function ($m) {
    $m->addDynamicProperty('foo', 'bar');
});

$m = new System\Models\EventLog;

$m->foo;              // 'bar'
$m->propertyExists('foo'); // true
isset($m->foo);       // false      <-- should be true
$m->foo ?? 'DEFAULT'; // 'DEFAULT'  <-- silently wrong
empty($m->foo);       // true

// Twig, with the default cms.enableTwigStrictVariables = false
// {{ m.foo }} renders '' instead of 'bar', {% if m.foo is defined %} is false

Plain Extendable objects are unaffected in the "wrong" direction because they define no __isset(), so ?? falls through to __get(); only models added the __isset() that shadows it.

Changes

  • ExtendableTrait::extendableIsset($name) — new helper that inspects extensionData directly (dynamic properties first, then behavior properties) and returns null when the property is not extension-provided. It deliberately does not route through extendableGet(), so attributes are not resolved twice and relations are not lazy loaded during what looks like a cheap existence check.
  • Database\Model::__isset() — consults the helper, falls back to the existing getAttribute() logic.
  • Halcyon\Model::__isset() — same, but only short-circuits on a positive result.

Null handling mirrors each reader, so isset() keeps agreeing with __get():

Case Result
Dynamic property, non-null true
Dynamic property, null falls through, exactly like extendableGet() skips it (same-named attribute/relation still resolves)
Behavior property, non-null true
Behavior property, null false (Eloquent's isset() semantics)
Halcyon behavior property, null falls through to the local attribute, matching extendableGet($key) ?? getAttribute($key)
Attribute / mutator / relation unchanged
Unknown property false

Compatibility notes

This is a semantic change and should land in a minor, per the notes in wintercms/winter#1536:

  1. isset() on a model now returns true for a non-null public property declared on a behavior where it previously returned false (e.g. Winter.Search's searchable). Code branching on isset($model->prop) can take a different path.
  2. $model->extensionProperty ?? $default now yields the property value instead of always the default. Plugins that worked around this (e.g. LukeTowers.EasyAudit) keep working, but anything depending on the old fallback will change.
  3. Twig: {{ model.extensionProperty }} and {% if model.extensionProperty is defined %} now resolve extension properties instead of rendering empty / false.
  4. Positive side effect: isset($model->extensionProperty) no longer runs hasRelation() (and its reflection scan) on the way to getAttribute().

Out of scope, unchanged on purpose: __unset() and offsetExists(), so isset($model['extensionProperty']) still reports false.

Core has no extension-provided properties today (addDynamicProperty() isn't called outside tests, and the core behaviors that declare public properties are all ControllerBehaviors attached to controllers, which use Extendable rather than Model), so nothing in Winter itself flips.

Tests

New coverage:

  • tests/Database/ModelTest.php — dynamic property (isset/empty/??), null dynamic property, behavior property, null behavior property, and the fall-through when a null dynamic property shares a name with a real attribute; plus attribute/unknown-property regressions.
  • tests/Extension/ExtendableTest.php — extendableIsset() returns true / false / null as documented.
  • tests/Halcyon/HalcyonModelTest.php — dynamic property and the null behavior property fall-through to a same-named attribute.

Verification done locally:

  • Targeted suites: ModelTest 25 tests, ExtendableTest 34 tests, HalcyonModelTest 21 tests — all green.
  • Red check: reverting src/ makes the new tests fail (ModelTest::testIssetIsExtensionAware, testIssetIsAwareOfBehaviorProperties, HalcyonModelTest::testIssetOnDynamicProperty); reverting only the null handling makes the two fall-through tests fail.
  • Full storm suite: 921 tests / 3539 assertions, 6 errors, all in ResizerTest because this machine's GD build has no JPEG support (imagecreatefromjpeg undefined) — unrelated to the change.
  • Full Winter CMS suite (wintercms/winter develop) against this branch: 1107 tests, 3688 assertions, 29 skipped, all green.
  • phpcs on all changed files: clean.

Refs wintercms/winter#1536 (issues are disabled on this repository).

`Database\Model::__isset()` resolved attributes and relations only, while
`__get()` goes through `extendableGet()` (dynamic properties, then behavior
properties, then the parent getter). `Halcyon\Model::__isset()` had the same
asymmetry.

Because both classes define `__isset()`, PHP routes `??`, `empty()` and Twig's
`is defined` / `default()` through it, so a property that reads back fine
reported as not set - eg `$model->extensionProperty ?? $default` always
returned the default, and `{{ model.extensionProperty }}` rendered empty
(Twig resolves object attributes through `isset()` as well).

Add `ExtendableTrait::extendableIsset()`, which inspects the extension data
directly instead of routing through `extendableGet()`, so attributes are not
resolved twice and relations are not lazy loaded during what looks like a
cheap existence check. It returns null when the property is not provided by
the extension system at all.

Null handling mirrors each reader, so `isset()` keeps agreeing with `__get()`:

- `extendableGet()` skips null dynamic properties, so `extendableIsset()` does
  too and the caller falls back to attributes/relations.
- A behavior property is resolved by `propertyExists()`, so a null value there
  reports "not set" (Eloquent's `isset()` semantics).
- `Halcyon\Model::__get()` uses `extendableGet($key) ?? getAttribute($key)`,
  so `Halcyon\Model::__isset()` only short-circuits on a positive result.

Behavior awareness is a deliberate semantic change: `isset()` on a model now
returns true for a non-null public property declared on a behavior, where it
previously returned false (eg `Winter.Search`'s `searchable`). See the
compatibility notes in wintercms/winter#1536; this targets the next minor.

Out of scope: `__unset()` and `offsetExists()` remain attribute/relation only,
so `isset($model['extensionProperty'])` is unchanged.

Refs wintercms/winter#1536
@coderabbitai

coderabbitai Bot commented Oct 27, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant