A booking site I maintain had a symptom that turned out to be exactly as bad as it looked. Every time a visitor opened the checkout page, they were bounced straight back to the homepage. No error, no white screen, just a smooth redirect. The URL was clearly correct, /checkout/?property=123&checkin=...&checkout=..., but the moment the page tried to load, the browser jumped to the front page. The entire booking flow was effectively dead, and the unsettling part: it had been that way since launch, close to two weeks, with not a single successful booking.
What made it deceptive was that to the site owner everything looked fine. The book button was there, property pages rendered nicely, and clicking book sent you to a correct checkout URL. Nothing on screen was red. It was the smooth redirect itself that disguised the bug as normal behavior.
Tracing it
A redirect with no error means some wp_redirect() is being called on purpose, not a crash. So I opened template-checkout.php and searched for redirect. It showed up fast, near the very top of the template, as a guard:
$property = Property::get_property_data( $id );
if ( empty( $property['id'] ) ) {
wp_redirect( home_url() );
exit;
}The logic looks reasonable: if the property is not valid, kick the visitor to the homepage. But if this guard is always true, every checkout redirects. So the question narrowed: is $property['id'] actually empty?
I dumped the contents of $property right after the call:
$property = Property::get_property_data( $id );
error_log( '[checkout] ' . print_r( $property, true ) );The output explained everything. The array was full: price_per_night, capacity, gallery, amenities, every meta field present. But there was no id key. None. $property['id'] pointed at a key that never existed, so its value was null, and empty( null ) is always true. The guard never had a chance to pass.
The root cause
I opened get_property_data(). It was designed to return only the property's meta fields: price, capacity, gallery, amenities. It never puts the post ID into the array, because the ID is the argument you pass in. From that method's point of view this is sensible: you already know the ID, why hand it back?
The trouble was that whoever wrote the checkout template assumed the opposite. They expected the returned array to carry an id key and used it as the property valid or not flag. Two assumptions quietly colliding.
Why did this reach production? Because the old flow was different. An earlier version used a ?booking=X parameter whose booking object was created server-side first, so the guard checked something else and happened to pass. When the flow moved to ?property=X directly, this new guard came in but was never actually exercised in a real browser. The Stripe scaffolding around it was tested with units, not with a human clicking. A bug that only surfaces when the page is genuinely opened slipped past every test that existed.
And it was not alone. Combing the same templates, I found a sibling of the same bug: code in template-checkout.php and template-owner-add-property.php called Property::get( $id ), a method that does not even exist, then accessed the result with object syntax: $property->price_per_night. But property data in this system is always an associative array, accessed with $property['price_per_night']. Same class of bug: the template guessed the shape of the data, and guessed wrong.
The fix
The key: do not validate a property's existence through a key that may not be in the meta array. Validate against the real source of truth, the post itself.
// WRONG: get_property_data() has no 'id' key, empty() is always true
$property = Property::get_property_data( $id );
if ( empty( $property['id'] ) ) {
wp_redirect( home_url() );
exit;
}Replace it with a get_post() check, then inject id and title into the array so the summary template still has what it needs:
$id = absint( $_GET['property'] ?? 0 );
$post = get_post( $id );
if ( ! $post || $post->post_type !== 'property' ) {
wp_redirect( home_url() );
exit;
}
$property = Property::get_property_data( $id );
$property['id'] = $post->ID;
$property['title'] = $post->post_title;Now the guard checks the right thing: whether the post truly exists and is a property. If it does, the flow continues; if not, then it redirects. And because I inject id plus title, the booking-summary template part can show the property title without an extra query.
For the object-syntax sibling, the fix was simply to match the data's real shape: drop the nonexistent Property::get() call, use get_property_data(), and access everything as an array.
// WRONG: nonexistent method, then object syntax on an associative array
$property = Property::get( $id );
$price = $property->price_per_night;
// RIGHT
$property = Property::get_property_data( $id );
$price = $property['price_per_night'];After both fixes, checkout stopped throwing people at the homepage and the first booking finally came through.
Takeaways
- A redirect with no error is not a crash: hunt for a deliberate
wp_redirect(), usually in a guard at the top of the template. - If a guard always fires, dump the variable it checks.
empty()on a missing key is alwaystrue, and it is silent. - Never assume a method returns a key it did not promise. Read the method body, do not infer from its name.
- Validate an entity's existence against the source of truth,
get_post(), not against an optional key in a meta array. - Check the data's shape before you access it: arrays use
['key'], objects use->prop. The wrong syntax on the wrong shape fails silently. - Bugs that only appear when a page is genuinely opened will not be caught by unit tests. Click the critical flow in a browser before you call it done.
![Checkout Always Redirects to the Homepage: `empty( $property['id'] )` Is Always `true` Because `get_property_data()` Has No `id` Key](/blog/covers/empty-id-check-checkout-redirect-get-property-data.webp)