April’s release is the largest change to WooCommerce since it existed, and the release notes undersell it as an API modernisation. What it actually does is put a layer between the product and its storage, which breaks a decade of code that read post meta directly and — much more interestingly — makes it possible for products to stop being posts at all.
The symptom
The upgrade was uneventful for about nine minutes. Then the debug log started filling, and a shop with fourteen plugins and a custom theme produced several thousand notices an hour.
PHP Notice: product_type was called incorrectly. Product properties should not
be accessed directly. Backtrace: .../themes/shop/woocommerce/content-product.php:41
PHP Notice: id was called incorrectly. Product properties should not be accessed
directly. Backtrace: .../plugins/shop-filters/includes/class-filter.php:88
PHP Notice: price was called incorrectly. Product properties should not be
accessed directly. Backtrace: .../plugins/feed-exporter/exporter.php:212Three of those were ours. The rest belonged to plugins, one of which had not been updated in a year and was reading the price of a variable product and getting null — silently, with no notice, because it was reading meta rather than a property.
Why it happens
A 2.6 product was a thin object wrapping a post, with public properties populated from post meta at construction. Anything could read $product->price, and anything could write update_post_meta($id, '_price', ...) and have the change appear. The storage and the model were the same thing.
3.0 makes the properties private behind getters and setters, and introduces a data store — an object that knows how to read and write a product, which the product itself does not. That separation is the point of the release, and every consequence follows from it.
// 2.6: the object is the post meta
class WC_Product {
public $id;
public $price; // read straight from _price at construction
}
// 3.0: the object has state, and something else knows how to persist it
abstract class WC_Data {
protected $data = array(); // private
protected $data_store; // knows about storage
public function get_prop( $prop, $context = 'view' ) { /* ... */ }
}
The fix
The mechanical half
The notices name a file and a line, so the theme and our own plugins were an afternoon of mechanical translation. Every property becomes a getter, and the setters exist too — a product is modified through methods and saved explicitly.
// before
$id = $product->id;
$price = $product->price;
$type = $product->product_type;
$stock = $product->get_stock_quantity();
update_post_meta( $product->id, '_price', 4900 );
// after
$id = $product->get_id();
$price = $product->get_price();
$type = $product->get_type();
$stock = $product->get_stock_quantity();
$product->set_price( 4900 );
$product->save(); // nothing is written until this
The explicit save() is the part that changes how code is structured rather than just how it is spelled. A function that used to update three meta keys and return now mutates an object and has to decide who saves it — which is an improvement, and it surfaces every place that was writing without meaning to.
The context argument on getters is worth knowing before it causes a bug: view runs the display filters and is the default, edit returns the stored value. An admin screen showing a price that a dynamic pricing plugin has discounted, and then saving it back, is how a discount gets baked into the base price permanently.
$product->get_price(); // 'view' — filtered, what a customer sees
$product->get_price( 'edit' ); // raw — what is stored
// so an admin form must read 'edit' and a template must read 'view'
The half that is not mechanical
Reads produce a notice. Writes do not, and that asymmetry is where the real work is. Code calling update_post_meta() to change a price still updates the meta row, and a product object loaded afterwards may or may not reflect it depending on whether the object was cached.
$ wp eval '
update_post_meta( 8841, "_price", 100 );
$p = wc_get_product( 8841 );
echo $p->get_price();
'
4900
# the meta row says 100. the object says 4900. both are "correct".The importer was the case that mattered. It wrote prices and stock for forty thousand products nightly using update_post_meta() for speed, and after the upgrade the catalogue pages showed the new prices — because they read meta through a code path that had not been migrated — while the cart used the object and charged the old ones.
The fix is not subtle and it is not fast: every write goes through the object.
// the importer, rewritten
foreach ( $rows as $row ) {
$product = wc_get_product( $row['id'] );
if ( ! $product ) {
continue;
}
$product->set_price( $row['price'] );
$product->set_regular_price( $row['price'] );
$product->set_stock_quantity( $row['stock'] );
$product->save();
}
Warning
That import went from four minutes to fifty-one. Loading and saving an object per row is dramatically slower than writing meta, and there is no fast path in this version — wc_update_product_stock() exists for stock alone and there is nothing equivalent for price. Batching and a longer window was the answer; there was no clever one.
A shim for the plugins you do not own
Three plugins were still reading properties directly and one of them was abandoned. Forking was disproportionate and the notices were filling the disk, so the pragmatic answer was to silence the specific ones we had assessed and leave the rest visible.
// mu-plugins/shop-wc3-notices.php
add_filter( 'doing_it_wrong_trigger_error', function ( $trigger, $function ) {
// only for the abandoned feed exporter, and only for reads we checked
$backtrace = wp_debug_backtrace_summary( null, 0, false );
foreach ( $backtrace as $frame ) {
if ( false !== strpos( $frame, 'plugins/feed-exporter/' ) ) {
return false;
}
}
return $trigger;
}, 10, 2 );
This is a bad thing to do and it is better than the alternatives, which were a disk full of notices or an unreviewed fork. It is scoped to one directory, it is in a file whose name says what it is, and it has a comment giving the date it should be reviewed. Two of the three plugins shipped compatible versions within six weeks.
The prize: custom tables become possible
The data store is registered through a filter, which means the class that knows how to load and save a product is replaceable without anything else in the plugin or the theme knowing.
add_filter( 'woocommerce_data_stores', function ( $stores ) {
$stores['product'] = 'Shop_Product_Table_Store';
return $stores;
} );
class Shop_Product_Table_Store extends WC_Product_Data_Store_CPT
{
public function read( &$product ) {
// from a flat table, not from wp_postmeta
}
public function update( &$product ) {
// and back again
}
}
That is not something to do this year. The interfaces are new, the CPT store is the only complete implementation, and every plugin in the ecosystem still assumes products are posts. What changed is that the door exists — the fix for a catalogue fighting wp_postmeta is no longer a denormalised index kept in sync by hooks, it is eventually a data store.
Verifying it worked
Three checks, in order of how much they proved.
# 1. the notices, which is the easy one
$ grep -c 'was called incorrectly' storage/logs/debug.log
0
# 2. the write paths, by comparing meta against the object
$ wp eval-file bin/audit-price-consistency.php
checked 41,208 products — 0 disagreements
# 3. an order placed end to end, at the price shown on the page
$ vendor/bin/phpunit --group checkout
OK (34 tests, 96 assertions)The second is the one that mattered. Notices going to zero only proves the reads were migrated; comparing the stored meta against what the object reports is what proves no write path is still going round the back. It found two — a bulk edit screen and a REST endpoint added last year.
Reading the same product twice
The object caching changed as well, and it is the source of the strangest bug in this migration. wc_get_product() returns a fresh object each call, populated from a cached data set — so two objects for the same product can disagree if one was modified and not saved.
$a = wc_get_product( 8841 );
$b = wc_get_product( 8841 );
$a->set_price( 100 );
echo $a->get_price(); // 100
echo $b->get_price(); // 4900 — a different object, unaware
$a->save();
echo $b->get_price(); // still 4900; $b was hydrated before the write
In 2.6 this was less visible because reads went to post meta and the object was thin. Now the object holds state, and a hook that loads a product while another piece of code is holding one is working with a snapshot. The rule that avoids it is to pass the product object down rather than re-fetching by id, which is better practice anyway and is not what most WooCommerce code does.
Ordering the migration so the site stays up
The temptation is to upgrade the plugin and fix the notices afterwards, which is what produced the price inconsistency described above. The order that worked, on a shop taking orders throughout, was to make the code version-agnostic first.
// deployed on 2.6, before the upgrade — works on both
function shop_product_price( $product ) {
return method_exists( $product, 'get_price' )
? $product->get_price()
: $product->price;
}
// then upgrade WooCommerce, then delete the shim
Three releases: compatibility shims, then the plugin upgrade, then removing the shims. It is slower and the middle step is the only irreversible one, which is the property worth buying — a rollback at that point restores a plugin version rather than a plugin version and a codebase.
The writes had to go first regardless. A shim can paper over a read on either version; there is no version of the importer that writes meta directly and is correct after the upgrade, so that rewrite blocked everything else.
What this costs
The import is twelve times slower and there is no version of this release where it is not. That is the honest headline cost, and on a catalogue large enough it changes what time the nightly job has to start.
The compatibility layer for third-party plugins is a liability with a review date that will be missed. It should be a list, in a file, with dates — and it should be shorter every quarter or it is not a migration aid, it is the new normal.
And the abstraction is only worth its cost if the data store is eventually used. A CRUD layer over the same post meta is a slower way to reach the same rows, dressed as architecture. The release is a good one because of what it enables; a shop that migrates the syntax and stops there has paid for the door and not opened it.