Skip to content

$page->meta() writes are silently lost after save() if meta() was touched before the page was saved #2308

Description

@outflux3

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

  1. 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.

  2. WireDataDB::save() (wire/core/WireData/WireDataDB.php:268) returns early on a
    falsy source id, without throwing:

    if(!$sourceID) return false;
  3. 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.

  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions