Summary
Calling $page->meta() on an unsaved page binds the meta store to page id 0 and caches that binding on the Page object. Every write is then a silent no-op — before and after the page is saved — while reads keep returning the value from memory, so the data appears to have been stored.
The pre-save write being dropped may well be intentional; a row cannot be written for a page that does not exist yet. The part that looks like a bug is that the object never recovers: a write made after a successful save(), on a page that now has an id, is also discarded, with no error and no warning.
Environment
- ProcessWire 3.0.270
- PHP 8.5.6
- MySQL / MariaDB, default
pages_meta table
Steps to reproduce
$p = new Page();
$p->template = 'basic-page';
$p->parent = $pages->get('/');
$p->title = 'probe';
$p->name = 'probe-' . time();
$p->meta('a', ['x' => 1]); // BEFORE save
echo $p->meta('a')['x']; // → 1 (looks like it worked)
$p->save(); // page now has a real id
$p->meta('b', ['x' => 2]); // AFTER save, same object
$pages->uncacheAll();
$fresh = $pages->get($p->id);
var_dump($fresh->meta('a')); // → NULL
var_dump($fresh->meta('b')); // → NULL
Control — identical except meta() is never touched before save():
$q = new Page();
$q->template = 'basic-page';
$q->parent = $pages->get('/');
$q->title = 'probe 2';
$q->name = 'probe2-' . time();
$q->save();
$q->meta('c', ['x' => 3]);
$pages->uncacheAll();
var_dump($pages->get($q->id)->meta('c')); // → ['x' => 3] (works)
Expected
Either the write succeeds, or it fails loudly. In particular $p->meta('b', …) — issued after a successful save(), on a page with a valid id — should persist.
Actual
Both a and b are absent from pages_meta. Nothing is thrown, nothing is logged, and the in-memory read returns the value as though it had been saved.
Why it happens
-
Page::meta() (wire/core/Page/Page.php:4228) lazily constructs the store and caches it on the object:
if($this->_meta === null) $this->_meta = $this->wire(new WireDataDB($this->id, 'pages_meta'));
On a new Page, $this->id is 0, so the instance is bound to source id 0.
-
WireDataDB::save() (wire/core/WireData/WireDataDB.php:268) returns early on a
falsy source id, without throwing:
if(!$sourceID) return false;
-
Nothing re-binds sourceID after the page is inserted. Page::$_meta is only reset
in __clone() (Page.php:665), so within one object's lifetime the id-0 binding is
permanent.
-
Reads are answered by WireData's in-memory cache before the DB is consulted, which
is what makes the failure invisible at the call site.
Note that sourceID() (WireDataDB.php:297) has a guard against ids < 1 that is
present but commented out, with the note "could interfere with some page clone
operations" — so the id-0 case appears to have been considered and deliberately allowed
to pass silently.
Documentation
The docblock for Page::meta() (Page.php:4180) states:
This meta data is managed in the DB. Setting a value immediately saves it in the DB […]
There is no caveat about unsaved pages, so the documented contract and the behaviour
disagree.
Suggested fix
Re-bind when the page has since gained an id — the setter is already public:
public function meta($key = '', $value = null) {
if($this->_meta === null) {
$this->_meta = $this->wire(new WireDataDB($this->id, 'pages_meta'));
} else if($this->id && !$this->_meta->sourceID()) {
$this->_meta->sourceID($this->id);
}
…
}
That alone fixes the post-save case, which is the one that seems hardest to justify as
intended.
Optionally, flushing the already-cached in-memory values to the DB at the moment of
re-binding would make the pre-save case work too, which is probably what most callers
expect when they set meta while building a page. That is a larger behavioural change, so
it may be better as a separate decision.
If the current behaviour is intended, a note in the Page::meta() docblock — that
values cannot be stored until the page has been saved, and that touching meta()
beforehand prevents later writes on that object — would be enough to prevent the same
afternoon of debugging.
Related
PagesEditor already maintains meta across other lifecycle events — copyTo() on clone
(PagesEditor.php:1683) and removeAll() on delete (PagesEditor.php:2158) — which is
partly why the absence of any handling on insert reads as an oversight rather than a
design choice.
Summary
Calling
$page->meta()on an unsaved page binds the meta store to page id0and caches that binding on the Page object. Every write is then a silent no-op — before and after the page is saved — while reads keep returning the value from memory, so the data appears to have been stored.The pre-save write being dropped may well be intentional; a row cannot be written for a page that does not exist yet. The part that looks like a bug is that the object never recovers: a write made after a successful
save(), on a page that now has an id, is also discarded, with no error and no warning.Environment
pages_metatableSteps to reproduce
Control — identical except
meta()is never touched beforesave():Expected
Either the write succeeds, or it fails loudly. In particular
$p->meta('b', …)— issued after a successfulsave(), on a page with a valid id — should persist.Actual
Both
aandbare absent frompages_meta. Nothing is thrown, nothing is logged, and the in-memory read returns the value as though it had been saved.Why it happens
Page::meta()(wire/core/Page/Page.php:4228) lazily constructs the store and caches it on the object:On a new Page,
$this->idis0, so the instance is bound to source id0.WireDataDB::save()(wire/core/WireData/WireDataDB.php:268) returns early on afalsy source id, without throwing:
Nothing re-binds
sourceIDafter the page is inserted.Page::$_metais only resetin
__clone()(Page.php:665), so within one object's lifetime the id-0 binding ispermanent.
Reads are answered by
WireData's in-memory cache before the DB is consulted, whichis what makes the failure invisible at the call site.
Note that
sourceID()(WireDataDB.php:297) has a guard against ids< 1that ispresent but commented out, with the note "could interfere with some page clone
operations" — so the id-0 case appears to have been considered and deliberately allowed
to pass silently.
Documentation
The docblock for
Page::meta()(Page.php:4180) states:There is no caveat about unsaved pages, so the documented contract and the behaviour
disagree.
Suggested fix
Re-bind when the page has since gained an id — the setter is already public:
That alone fixes the post-save case, which is the one that seems hardest to justify as
intended.
Optionally, flushing the already-cached in-memory values to the DB at the moment of
re-binding would make the pre-save case work too, which is probably what most callers
expect when they set meta while building a page. That is a larger behavioural change, so
it may be better as a separate decision.
If the current behaviour is intended, a note in the
Page::meta()docblock — thatvalues cannot be stored until the page has been saved, and that touching
meta()beforehand prevents later writes on that object — would be enough to prevent the same
afternoon of debugging.
Related
PagesEditoralready maintains meta across other lifecycle events —copyTo()on clone(
PagesEditor.php:1683) andremoveAll()on delete (PagesEditor.php:2158) — which ispartly why the absence of any handling on insert reads as an oversight rather than a
design choice.