Skip to content

FieldtypeOptions::___getInputfield triggers foreach warning + cast notice on output-formatted pages when auto-initializing a required field #2253

@adrianbj

Description

@adrianbj

FieldtypeOptions::___getInputfield triggers foreach warning + cast notice on output-formatted pages when auto-initializing a required field

Summary

When ProcessPageEdit renders the inputfield for a FieldtypeOptions field that has required=1 and a non-empty initValue, and the host page has outputFormatting: true and an empty value for that field, two PHP errors are emitted per render:

Warning: foreach() argument must be of type array|object, int given
  in wire/modules/Fieldtype/FieldtypeOptions/FieldtypeOptions.module:339

Notice: Object of class ProcessWire\SelectableOptionArray could not be converted to int
  in wire/core/Page/PageValues.php:1114

Both errors originate from a single $page->set() call. They are warnings, not fatals, but they pollute logs and Tracy bluescreens.

This is not a recent regression. All three contributing pieces of PW code have been present since the initial commit of this repo in September 2016 (carried over from the prior ryancramerdesign/ProcessWire/devns branch). What changed is PHP's strictness — foreach over scalars became a Warning in PHP 8.0, and object→int casts emit a Notice — so sites on PHP 8+ now see the warnings that earlier PHP versions silently swallowed. See When was this introduced below for the full git bisection. 3.0.262/3.0.263 in the Environment line are simply what I happened to reproduce on; the underlying bug applies to every version of PW 3.x as far back as I traced.

Environment

  • ProcessWire: confirmed on 3.0.262 and 3.0.263 (dev). Earlier versions likely also affected — untested.
  • PHP: 8.5.6
  • Tracy: 2.12.0
  • Trigger context: editing a page in the admin where the page (or a nested page such as a RepeaterMatrixPage, RepeaterPage, or FieldsetPage) has outputFormatting: true and contains a required Options field with an empty value.

Repro

  1. Create a FieldtypeOptions field. Set required = 1. Set initValue to an integer matching one of its option IDs. Leave defaultValue empty or set.
  2. Add the field to a context that produces a host page with outputFormatting: true. Easiest: place the field inside a FieldtypeRepeaterMatrix matrix type, since matrix child pages default to of(true).
  3. Create a host record (e.g. a page using a template that has that matrix field), and add a matrix item so a RepeaterMatrixPage exists in the DB with the Options field empty. A "ready"/placeholder matrix item also reproduces — the UI does not need to show the item.
  4. Open the host page in ProcessPageEdit.

Expected: form renders cleanly.
Actual: the warning and notice above are emitted (typically 2× per render in multi-language setups).

Root cause

Three core code paths combine to produce the errors:

A. FieldtypeOptions::___getInputfield passes a raw int to $page->set()

wire/modules/Fieldtype/FieldtypeOptions/FieldtypeOptions.module:127-130:

$value = $page->getUnformatted($field->name);
if($field->required && !$field->requiredIf) {
    if(empty($value) || !wireCount($value)) {
        $page->set($field->name, $field->get('initValue'));
    }
}

initValue is stored as an integer option ID. This goes through the public set() API, which routes to PageValues::setFieldValue().

B. PageValues::setFieldValue runs a corruption check that calls formatValue on raw scalars

wire/core/Page/PageValues.php:1106-1115:

if($value instanceof PageFieldValueInterface) {
    if($value->formatted()) $isCorrupted = true;
} else if($page->of()) {
    // check if value is modified by being formatted
    $result = $fieldtype->_callHookMethod('formatValue', array($page, $field, $value));
    if($result != $value) $isCorrupted = true;
}

The check is designed to detect callers setting an already-formatted value back onto an of(true) page (which would corrupt the data on save). For Fieldtypes whose formatted representation is an object (SelectableOptionArray, Pageimages, etc.), a raw scalar input cannot, by definition, be "already formatted". But the check feeds the scalar through formatValue anyway.

C. FieldtypeOptions::___formatValue assumes its input is iterable

wire/modules/Fieldtype/FieldtypeOptions/FieldtypeOptions.module:337-345:

public function ___formatValue(Page $page, Field $field, $value) {
    $_value = $this->getBlankValue($page, $field);
    foreach($value as $option) {     // ← Warning when $value is int
        $_option = clone $option;
        $_value->add($_option);
    }
    $_value->of(true);
    return $_value;
}

foreach(int) → Warning at line 339.

Then back in PageValues.php:1114, $result != $value loose-compares the returned (empty) SelectableOptionArray to the int → PHP attempts an object→int cast → Notice at line 1114.

Full stack (abbreviated)

ProcessWire\FieldtypeOptions->___formatValue(...)
  in /wire/modules/Fieldtype/FieldtypeOptions/FieldtypeOptions.module:339
ProcessWire\PageValues->setFieldValue(...)
  in /wire/core/Page/PageValues.php:1113
ProcessWire\Page->set('hook_alignment', 5)
  in /wire/modules/Fieldtype/FieldtypeOptions/FieldtypeOptions.module:130
ProcessWire\FieldtypeOptions->___getInputfield(...)
  in /wire/core/Field/Field.php:1000
ProcessWire\InputfieldRepeater->preloadInputfieldAssets()
  in /wire/modules/Fieldtype/FieldtypeRepeater/InputfieldRepeater.module:806
ProcessWire\InputfieldRepeater->renderReady(...)
  in /site/modules/FieldtypeRepeaterMatrix/InputfieldRepeaterMatrix.module:920
...
ProcessWire\ProcessPageEdit->renderEdit()

Suggested fixes (any one of these suppresses the symptom; (1) is the most general)

1. Gate the corruption check on is_object($value) in PageValues::setFieldValue

Most strategic — protects every Fieldtype, not just FieldtypeOptions.

 if($value instanceof PageFieldValueInterface) {
     if($value->formatted()) $isCorrupted = true;
-} else if($page->of()) {
+} else if($page->of() && is_object($value)) {
     $result = $fieldtype->_callHookMethod('formatValue', array($page, $field, $value));
     if($result != $value) $isCorrupted = true;
 }

Rationale: scalars (int, string, null) cannot be "already formatted" — the formatted representation of an Options/Pages/Image/etc. field is always an object. Calling formatValue on a scalar serves no purpose in the corruption-detection sense and only risks tripping Fieldtypes whose formatValue reasonably assumes the contract that it receives a sanitized/woken value.

2. Use setQuietly() in FieldtypeOptions::___getInputfield

Targeted — the auto-init is internal scaffolding, not user input, so the set() shouldn't trigger change tracking or the corruption check anyway.

 if($field->required && !$field->requiredIf) {
     if(empty($value) || !wireCount($value)) {
-        $page->set($field->name, $field->get('initValue'));
+        $page->setQuietly($field->name, $field->get('initValue'));
     }
 }

3. Guard FieldtypeOptions::___formatValue against non-iterable input

Defense in depth — prevents the foreach warning regardless of caller.

 public function ___formatValue(Page $page, Field $field, $value) {
     $_value = $this->getBlankValue($page, $field);
+    if(!is_iterable($value)) return $_value;
     foreach($value as $option) {
         $_option = clone $option;
         $_value->add($_option);
     }
     $_value->of(true);
     return $_value;
 }

(1) is the broadest and probably the right place. (2) is also worth doing on its own merits — auto-init is not the kind of write that should go through the user-facing set() path.

Workaround for site authors until a fix lands

// site/ready.php
$this->wire()->addHookBefore('Field::getInputfield', function($event) {
    $field = $event->object;
    if(!($field->type instanceof FieldtypeOptions)) return;
    $page = $event->arguments(0);
    if($page->of()) $page->of(false);
});

Forces of(false) before an Options inputfield is built, which causes setFieldValue to skip the corruption check entirely. We have to hook Field::getInputfield rather than FieldtypeOptions::getInputfield because the latter is declared without the ___ prefix and is therefore not exposed to the hook system; the filter on $field->type instanceof FieldtypeOptions keeps the hook scoped to the affected Fieldtype. Safe in admin context — inputfield rendering does not require of(true).

Notes on visibility / discoverability

The bug only surfaces under a specific combination of conditions, which explains why it hadn't been caught earlier:

  • the field must be required AND have a non-null initValue,
  • the host page must have outputFormatting: true (most directly-edited pages have of(false), but matrix/repeater/FieldsetPage children default to of(true)),
  • the field's current value must be empty for that host page.

On a site with several required Options fields used inside RepeaterMatrix types, this hits any time an editor opens a page whose matrix children have not yet been edited (including "ready" placeholder items that aren't visible in the editor UI).

When was this introduced

All three contributing pieces of code have been present since the initial commit of processwire/processwire (bac5b0de, 2016-09-02), carried over from the prior ryancramerdesign/ProcessWire devns branch. Specifically:

  • The auto-init $page->set($field->name, $field->initValue) call in FieldtypeOptions::___getInputfield — present at line 107 of the initial commit. The only later change (703fa29c) was a rename to $field->get('initValue'), behaviorally identical.
  • The ___formatValue body with the bare foreach($value as $option) — present at line 276 of the initial commit, unchanged since.
  • The corruption-check $field->type->formatValue($this, $field, $value) != $value in Page::setFieldValue — present at line ~858 of the initial commit. It was relocated to PageValues::setFieldValue during the major Page-class refactor in ad4e359b (2022-09-02), with the same logic.

So the latent bug has existed since the inception of this repo, and likely earlier. What changed is PHP's behavior around silent-vs-loud handling of foreach over scalars and object→int casts. The conditions for the warnings being emitted tightened with PHP 7.4 → 8.0, which is when this likely started showing up in user-facing logs.

Confirmed reproducing on PW 3.0.262 and 3.0.263 (current dev tip).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions