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
- Create a
FieldtypeOptions field. Set required = 1. Set initValue to an integer matching one of its option IDs. Leave defaultValue empty or set.
- 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).
- 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.
- 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).
FieldtypeOptions::___getInputfieldtriggers foreach warning + cast notice on output-formatted pages when auto-initializing a required fieldSummary
When
ProcessPageEditrenders the inputfield for aFieldtypeOptionsfield that hasrequired=1and a non-emptyinitValue, and the host page hasoutputFormatting: trueand an empty value for that field, two PHP errors are emitted per render: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/devnsbranch). What changed is PHP's strictness —foreachover 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
RepeaterMatrixPage,RepeaterPage, orFieldsetPage) hasoutputFormatting: trueand contains a required Options field with an empty value.Repro
FieldtypeOptionsfield. Setrequired = 1. SetinitValueto an integer matching one of its option IDs. LeavedefaultValueempty or set.outputFormatting: true. Easiest: place the field inside aFieldtypeRepeaterMatrixmatrix type, since matrix child pages default toof(true).RepeaterMatrixPageexists in the DB with the Options field empty. A "ready"/placeholder matrix item also reproduces — the UI does not need to show the item.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::___getInputfieldpasses a raw int to$page->set()wire/modules/Fieldtype/FieldtypeOptions/FieldtypeOptions.module:127-130:initValueis stored as an integer option ID. This goes through the publicset()API, which routes toPageValues::setFieldValue().B.
PageValues::setFieldValueruns a corruption check that callsformatValueon raw scalarswire/core/Page/PageValues.php:1106-1115: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 throughformatValueanyway.C.
FieldtypeOptions::___formatValueassumes its input is iterablewire/modules/Fieldtype/FieldtypeOptions/FieldtypeOptions.module:337-345:foreach(int)→ Warning at line 339.Then back in
PageValues.php:1114,$result != $valueloose-compares the returned (empty)SelectableOptionArrayto the int → PHP attempts an object→int cast → Notice at line 1114.Full stack (abbreviated)
Suggested fixes (any one of these suppresses the symptom; (1) is the most general)
1. Gate the corruption check on
is_object($value)inPageValues::setFieldValueMost 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
formatValueon a scalar serves no purpose in the corruption-detection sense and only risks tripping Fieldtypes whoseformatValuereasonably assumes the contract that it receives a sanitized/woken value.2. Use
setQuietly()inFieldtypeOptions::___getInputfieldTargeted — 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::___formatValueagainst non-iterable inputDefense 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
Forces
of(false)before an Options inputfield is built, which causessetFieldValueto skip the corruption check entirely. We have to hookField::getInputfieldrather thanFieldtypeOptions::getInputfieldbecause the latter is declared without the___prefix and is therefore not exposed to the hook system; the filter on$field->type instanceof FieldtypeOptionskeeps the hook scoped to the affected Fieldtype. Safe in admin context — inputfield rendering does not requireof(true).Notes on visibility / discoverability
The bug only surfaces under a specific combination of conditions, which explains why it hadn't been caught earlier:
requiredAND have a non-nullinitValue,outputFormatting: true(most directly-edited pages haveof(false), but matrix/repeater/FieldsetPage children default toof(true)),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 priorryancramerdesign/ProcessWiredevnsbranch. Specifically:$page->set($field->name, $field->initValue)call inFieldtypeOptions::___getInputfield— present at line 107 of the initial commit. The only later change (703fa29c) was a rename to$field->get('initValue'), behaviorally identical.___formatValuebody with the bareforeach($value as $option)— present at line 276 of the initial commit, unchanged since.$field->type->formatValue($this, $field, $value) != $valueinPage::setFieldValue— present at line ~858 of the initial commit. It was relocated toPageValues::setFieldValueduring the major Page-class refactor inad4e359b(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
foreachover 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).