A client's property-listing site showed a price on every listing, and the request sounded trivial on paper: a EUR/USD currency toggle in the header. I started with the fun part, the conversion helper: take a numeric amount plus a currency code, multiply by the rate, return a formatted price string with the right symbol. The function was small, pure, easy to unit-test, and every test was green. I wired the toggle in, clicked from EUR to USD, and... nothing happened. Every price on the page rendered exactly as before, euro symbol and all.
My first instinct was to blame the layer closest to the eye: JavaScript. Maybe the toggle event never bound, maybe the currency state did not persist across pages. I checked each piece — the state changed, the cookie was set, the page re-rendered with the new currency context. I even re-suspected the formatter, but its tests kept passing: feed it 450 and 'USD', get the correct dollar string back. Every component worked in isolation. The broken thing was not visible in any single component.
The investigation: everything correct, nothing running
Eventually I stopped guessing and looked at what the listing template actually did when it rendered a price:
// the old render path in the listing template
$price = get_post_meta( $post_id, 'listing_price', true );
echo esc_html( $price );
var_dump( $price ); // string(6) "€450"Two things fell out at once. First, the template never called my formatter; it passed the raw meta value straight to the page. Second, string(6) for what I had assumed were three digits. Those three extra bytes are the euro symbol in UTF-8, baked into the data itself.
Root cause: presentation frozen into data
The prices on this site had never been stored as numbers. For years, content editors had pasted prices into the meta field exactly as they wanted them to appear on the page: '€450', '€1,200', symbol and thousands separator included. The old render code just passed the string through untouched, so for years everything "looked right" and nobody had a reason to complain.
My formatter expected a numeric amount plus a currency code. It was never in the render path at all, and even if it had been, there was nothing for it to convert: you cannot multiply an exchange rate into a string with a currency glyph baked in. The toggle worked. The formatter worked. They simply never met the data. That is why the symptom was "nothing happens" rather than an error: nothing failed, because none of my code ever ran.
The fix
The fix had three layers, and the order matters.
First, migrate the data. A one-time script that reads every stored price, strips symbols and separators, and stores a plain numeric plus a separate currency field:
// one-time migration: strip symbols and separators, keep the number
$listings = get_posts( array(
'post_type' => 'listing',
'numberposts' => -1,
'fields' => 'ids',
) );
foreach ( $listings as $listing_id ) {
$raw = get_post_meta( $listing_id, 'listing_price', true );
$amount = preg_replace( '/[^0-9.]/', '', $raw ); // '€1,200' becomes '1200'
if ( '' === $amount ) {
error_log( "unparseable price on listing {$listing_id}: {$raw}" );
continue;
}
update_post_meta( $listing_id, 'listing_price', (float) $amount );
update_post_meta( $listing_id, 'listing_currency', 'EUR' );
}Anything unparseable got logged and fixed by hand; there were only a handful. The point: after this script ran, the database contained data, not presentation.
Second, one door for every price render. Every place that displays a price — listing cards, detail pages, search results — now has to go through a single helper:
function format_price( $amount, $currency ) {
$rates = array( 'EUR' => 1.0, 'USD' => 1.08 );
$symbols = array( 'EUR' => '€', 'USD' => '$' );
$converted = (float) $amount * $rates[ $currency ];
return $symbols[ $currency ] . number_format( $converted, 0, '.', ',' );
}
// the new render path: every price goes through here, no exceptions
$amount = get_post_meta( $post_id, 'listing_price', true );
$currency = current_display_currency(); // 'EUR' or 'USD' from the switcher
echo esc_html( format_price( $amount, $currency ) );The moment this path was in place, the toggle "started working" — even though I never touched the toggle itself. Only one thing changed: the formatter finally stood between the data and the page.
Third, lock the door. The migration is worthless if an editor pastes '€450' again next week. I changed the admin price field to a numeric input, plus sanitization on save so a formatted string cannot slip back in through any path:
// meta box field: numeric input only
printf(
'<input type="number" step="1" min="0" name="listing_price" value="%s">',
esc_attr( get_post_meta( $post->ID, 'listing_price', true ) )
);
// sanitize on save, so nothing formatted survives even a paste
add_action( 'save_post_listing', function ( $post_id ) {
if ( isset( $_POST['listing_price'] ) ) {
$clean = preg_replace( '/[^0-9.]/', '', wp_unslash( $_POST['listing_price'] ) );
update_post_meta( $post_id, 'listing_price', (float) $clean );
}
} );The takeaway
The core lesson is a classic, but it was paid for in real hours: store data, not presentation. 450 is data. '€450' is a display decision frozen into data, and once frozen, the information inside it is buried under its own formatting. My checklist since this incident:
- When a switcher or toggle "does nothing", do not start debugging the switcher. Dump the raw stored value first; the presentation may already be baked in there, and your new code may never have been in the path at all.
- Route every value render through one formatter helper. A single template echoing meta directly is the one that will leak the moment requirements change.
- Make invalid states unenterable. A numeric input plus sanitize-on-save is far cheaper than a second migration.
The toggle itself was never wrong. It was just never given a number to toggle.
