Conditional logic in WordPress is not a pile of if statements scattered through functions.php. It is a contract with the main query: core decides the context of the HTTP request, the conditional tags read that context, and your code reacts. In 2026, with block themes and Full Site Editing (FSE), PHP still governs assets, capabilities, cache and every branch the site editor cannot express.
This guide treats conditional logic as engineering: when is_singular() misleads you, when in_the_loop() fails outside the Loop, why current_user_can() and page cache do not mix, and how to isolate helpers you can test. Taxonomy (in_category vs has_term) comes at the end as a concrete case, not as the only subject.
Where conditional tags read their state from
Conditional tags do not inspect the URL by hand. After parse_request and WP_Query, the global $wp_query object holds flags (is_single, is_page, is_front_page, is_category and others). Functions such as is_singular() and is_front_page() are wrappers around those flags.
Practical consequence: call a conditional tag too early, before wp / template_redirect, and the result is undefined or false. The handbook says it plainly: a conditional tag placed directly in functions.php will not work, it has to sit inside a function hooked to a later action or filter. In plugins, the safe places for template branches and enqueues are wp_enqueue_scripts, template_redirect or later hooks documented in Hooks.
Another consequence: a secondary WP_Query (widget, recent posts block, shortcode) does not change the flags of the main query. is_singular() keeps describing the requested page, not the post the widget is iterating over. For data about a specific post, pass the ID or the WP_Post object to the post and term APIs.
Common mistakes with is_singular and is_front_page
is_singular() returns true for a single post, page or CPT. It does not distinguish the type. In WooCommerce stores is_singular() is true on product, which leads many themes to load blog CSS on the product page. Prefer is_singular( 'post' ), is_page(), or is_singular( array( 'post', 'page' ) ) with an explicit list.
is_front_page() and is_home() remain the classic confusion:
- With “Your latest posts” under Settings > Reading, the homepage is both
is_front_page()andis_home(). - With a static page as the homepage and another page as the “Posts page”, the homepage is only
is_front_page(); the posts feed isis_home()and notis_front_page().
Code that runs if ( is_home() ) to “show the homepage hero” breaks the moment the client switches Reading to a static page. The stable pattern for the public homepage is is_front_page(). For the blog archive, use is_home() && ! is_front_page().
A production case from Gdynia: a theme loaded its slider only under is_home(). After the homepage moved to a block-built page, the slider vanished and the /blog/ archive inherited the carousel JavaScript. One line, is_front_page(), would have been enough.
is_singular() is also false on requests that look like “a page” to the user but are not singular in the query: search (is_search()), 404 (is_404()), and REST requests, which do not go through the theme’s main query. Before assuming “there is a current post”, check get_queried_object() and its type.
How to use in_the_loop and has_term outside the Loop
Functions such as the_title(), the_content() and several post conditionals (in_category() without an ID) depend on the global post set by the Loop. in_the_loop() tells you whether the code runs between while ( have_posts() ) : the_post(); and the end of that Loop.
Outside the Loop, in header.php, a body_class filter or wp_footer, in_category( 'news' ) without its second argument reads the global post, which can be the last post of the previous query, a post from a widget, or null. Typical symptom: the “news category” CSS class shows up on the archive, or the legal notice appears only on the last article of the listing.
Safe pattern:
function wppoland_is_news_singular(): bool {
if ( ! is_singular( 'post' ) ) {
return false;
}
$post_id = (int) get_queried_object_id();
if ( $post_id <= 0 ) {
return false;
}
return has_term( 'news', 'category', $post_id );
}Use get_queried_object_id() for the requested document; use the post ID inside a secondary Loop when you are iterating results. Do not mix the two.
in_the_loop() helps you decide whether you can trust the global post. If you are outside the Loop and need data about the current document, do not “enter” the Loop artificially just to read meta: read it by the main query’s ID.
For code that runs with no request context at all, such as a background job or a REST endpoint, is_object_in_term( $object_id, $taxonomy, $terms ) takes the object ID as its first argument and does not need a global post.
current_user_can and logged-in branches
current_user_can( 'edit_posts' ) and friends read the current user’s session and capabilities. They are the correct API for admin UI, edit shortcuts and editor-only content.
Three rules that prevent incidents:
- Never mix different HTML for anonymous and logged-in visitors in the same document if that document is served from a shared page cache. The edge or the cache plugin stores the first visitor’s variant. An editor sees the “Edit” button; the next anonymous visitor receives the same HTML.
- For logged-in branches, use cache-bypass cookies (the default in most page cache plugins), ESI, or serve the branch only through authenticated JavaScript/REST after the static HTML.
- Capabilities are not roles. Test
current_user_can( 'manage_options' ), notcurrent_user_can( 'administrator' ): the second is not the API’s stable contract.
In classic themes a frequent pattern is hiding the admin bar from subscribers. Do it with a filter on show_admin_bar, not with conditional CSS in cached HTML:
add_filter(
'show_admin_bar',
static function ( $show ) {
if ( is_user_logged_in() && ! current_user_can( 'edit_posts' ) ) {
return false;
}
return $show;
}
);The filter runs in the PHP request; the anonymous page cache never sees it because the logged-in user is already outside that cache.
When to use the template hierarchy and when PHP conditionals
The template hierarchy resolves files (single-product.php, category-news.php, front-page.php). Conditional tags resolve branches inside a shared file (header.php, functions.php, a partial).
Prefer the hierarchy when the markup diverges structurally: a CPT with its own layout deserves single-{post_type}.php (or the block theme equivalent: a dedicated HTML template). Prefer PHP conditionals when the difference is small: a notice, an enqueue, a class on body.
Anti-pattern: a single generic page.php with twenty chained if ( is_page( 'contact' ) ). That duplicates the hierarchy in PHP, makes code review harder and stops FSE (or a child theme) from replacing just one template. Move pages with distinct markup into their own templates; leave conditionals for fine variation.
Rule of thumb: if the branch fits a file name in the hierarchy, use the file. If the branch depends on a capability, cookie, A/B test or feature flag, use PHP (or a block with server-side logic from a plugin).
What are the limits of FSE in block themes
FSE moves templates and template parts into HTML in the site editor. It does not remove conditional tags.
The site editor covers visual variation by template type (singular, archive, 404). It does not reliably cover:
- Conditional enqueue of heavy scripts (maps, calculators, players).
- Branches by capability (
current_user_can). - Recursive taxonomy logic (parent includes children).
- ERP or WooCommerce integrations that depend on product meta in the current query.
- Anything that needs a secondary
WP_Querywith dynamic arguments.
Block themes still load functions.php. That is where wp_enqueue_scripts, body_class filters and term helpers live. Blocks with “variations” in the editor do not replace has_term() when the business rule is “if the product sits in any descendant of clothing”.
Operational boundary: markup only in the editor, runtime decisions in PHP. Mixing the two layers without a clear contract (for example a block pattern that assumes is_front_page() and a plugin that alters the main query) produces regressions that are hard to reproduce on staging.
Taxonomies: in_category versus has_term
in_category() is a historical shortcut for the category taxonomy. It does not cover product_cat, post_tag or taxonomies registered with register_taxonomy(). Its reference page lists two parameters: the category (ID, name, slug or an array of them) and an optional post.
has_term( $term, $taxonomy, $post = null ) is the general API. For new code, prefer has_term() even for native categories: the signature is explicit and the same helper serves WooCommerce and CPTs.
Native checks are an exact match. A post only in apple does not satisfy in_category( 'fruit' ) just because apple is a child of fruit. Core offers no “is in this branch of the tree” in a single call. Use get_term_children() (and object cache) in a helper:
function wppoland_post_in_term_branch( int $post_id, string $term_slug, string $taxonomy ): bool {
$term = get_term_by( 'slug', $term_slug, $taxonomy );
if ( ! $term instanceof WP_Term ) {
return false;
}
if ( has_term( (int) $term->term_id, $taxonomy, $post_id ) ) {
return true;
}
$children = get_term_children( (int) $term->term_id, $taxonomy );
if ( is_wp_error( $children ) || $children === [] ) {
return false;
}
return has_term( $children, $taxonomy, $post_id );
}WooCommerce trap: is_product_category() describes the product category archive, not the product in the Loop. WooCommerce has its own page conditionals (is_product(), is_shop(), is_product_category()), and all of them describe the request. Inside a product loop use has_term( $slug, 'product_cat', $post_id ). Learn more about checking custom taxonomy terms and showing the current and parent category.
Page cache with logged-in users in WordPress
Page cache (nginx fastcgi_cache, Cloudflare, full-page cache plugins) serves ready HTML to anonymous visitors. Any if ( is_user_logged_in() ) or current_user_can() baked into that HTML is a UI leak or, in the other direction, missing content.
Patterns that work:
- Separate: identical public HTML for all anonymous visitors; logged-in UI through an authenticated endpoint or an uncached fragment.
- Bypass: session cookies make the proxy skip the cache for logged-in users (cost: lower hit ratio).
- Do not condition critical homepage assets on
is_user_logged_in()in the PHP of the cached document.
“Safe” conditionals under anonymous page cache: is_front_page(), is_singular( 'post' ), has_term( $slug, 'category', $queried_id ), everything that depends only on the public URL and query. “Dangerous” conditionals in the same document: capabilities, user preferences, IP geolocation without a vary, A/B tests without a vary cookie.
On projects with edge cache in the EU, document the vary matrix (cookie, header) next to the helper. Without it, the next “promo bar for customers” plugin goes back to sharing HTML.
How to test conditional tags with PHPUnit
Inline conditionals in templates are hard to test. Extract pure (or nearly pure) predicates that receive explicit state:
function wppoland_should_load_map_assets( bool $is_contact_page, bool $user_can_edit ): bool {
return $is_contact_page || $user_can_edit;
}In the theme:
add_action(
'wp_enqueue_scripts',
static function () {
$should = wppoland_should_load_map_assets(
is_page( 'contact' ),
current_user_can( 'edit_pages' )
);
if ( ! $should ) {
return;
}
wp_enqueue_script( 'wppoland-maps', get_theme_file_uri( 'assets/maps.js' ), array(), '1.0.0', true );
}
);In PHPUnit (with the WordPress test suite or doubles), you test wppoland_should_load_map_assets( true, false ) without bootstrapping $wp_query. The same pattern applies to taxonomy branches: pass $post_id and slugs, do not read globals inside the predicate.
Useful conventions:
- A project prefix in the name (
wppoland_) to avoid collisions. - An explicit
boolreturn; no HTML echo inside the helper. - One helper answers one business question (“is this a news single?”), not a switch with side effects.
Performance of has_term and get_term_children
has_term() and conditional tags on the main query are cheap when the object cache is warm. get_term_children() on deep taxonomies can cost more; memoise the result per request (a local static or wp_cache_get / wp_cache_set with its own group).
Avoid calling the same predicate dozens of times in a Loop: compute it once before the Loop or cache it per $post_id. On archives with 30 products and a recursive product_cat check, the difference shows in TTFB on shared hosting.
Do not use raw SQL for category “shortcuts” when has_term() is enough: you lose the object cache and the $wpdb preparation core already does.
is_main_query and pre_get_posts: change only the main query
To change what an archive shows (number of posts, excluding a category, ordering), the right hook is pre_get_posts, not a new WP_Query inside the template. The hook receives the WP_Query object by reference and runs for every query: the main one, the secondary ones from widgets and blocks, and those in the admin.
Two rules from the pre_get_posts reference prevent most mistakes:
- Use the methods of the object you received (
$query->is_main_query(),$query->is_category()), not the global functions. The documentation explains thatis_main_query()tests the global$wp_queryinstance, not the one the hook passed. The is_main_query() page calls that use “circular”. - Guard the admin with
is_admin(). Without it, the same rule also changes the post list in wp-admin.
add_action(
'pre_get_posts',
static function ( WP_Query $query ): void {
if ( is_admin() || ! $query->is_main_query() ) {
return;
}
if ( $query->is_category( 'news' ) ) {
$query->set( 'posts_per_page', 20 );
}
}
);The same reference warns that some conditional tags do not work yet inside pre_get_posts: is_front_page() fails, is_home() works. If the rule is “only on the static homepage”, decide with $query->get( 'page_id' ) compared against get_option( 'page_on_front' ), or move the logic to template_redirect, where the query is already resolved.
Which pages is_archive covers and when to use is_tax
is_archive() is an aggregate. In core (class-wp-query.php) the is_archive flag becomes true when the query is a post type archive, a date, author, category or tag archive, or a custom taxonomy archive. The conditional tags handbook sums it up: category, tag, author and date pages are all archives.
That has direct consequences:
is_archive()alone loads the same CSS on the author archive and on theproduct_catarchive. If you only want blog category archives, useis_category().is_tax()returns false on category and tag archives. The is_tax() reference says it is true for custom taxonomy archives and false for the built-in taxonomies. To cover all three, writeis_category() || is_tag() || is_tax().is_post_type_archive( 'product' )is only true if the post type was registered withhas_archive => true, as the same handbook points out.
None of these functions says anything about an individual post. On a single, is_tax() is false even if the post has the term; there the right question is has_term().
is_page_template in block themes
is_page_template() has also changed meaning with block themes, without changing its signature. In core (post-template.php) the function returns false outside is_singular() and then compares its argument with get_page_template_slug() of the queried object, that is, with the value stored on the post.
In a classic theme that value is the PHP file name, so is_page_template( 'landing.php' ) works. In a block theme, custom templates registered under customTemplates in theme.json use as their name the HTML file name without the extension, according to the custom templates handbook. A /templates/landing.html template is checked with is_page_template( 'landing' ). Copying conditions from a classic theme into a block theme without adjusting this argument produces an if that is never true, with no warning and no error.
Checklist for conditional logic in WordPress
- Does the conditional tag run after the main query is ready?
- Do
is_front_page()vsis_home()match Settings > Reading? - Do term and category checks use an explicit post ID outside the Loop?
- Does any branch depend on
current_user_can()in page-cached HTML? - Does the markup divergence fit the template hierarchy instead of a giant
if? - Does the helper have tests or, at least, documented manual cases (anonymous, editor, archive, singular)?
- Do
pre_get_postscallbacks use$query->is_main_query()and excludeis_admin()?
Summary
Conditional tags read the main query; treat them as a context API, not URL parsing. Separate the template hierarchy (structure) from conditional PHP (fine variation and runtime). In FSE the editor covers templates; PHP still covers assets, capabilities and taxonomy rules. has_term() replaces in_category() in new code; logged-in branches stay out of cached anonymous HTML. Helpers with explicit arguments keep the logic reviewable and testable.
Conditional logic placed badly in a template is the kind of bug that only shows up on the post nobody tested. If you want those templates reviewed by someone who has caught the bug before, see our WordPress development practice.







