WordPress Gotchas: Hooks, Template Hierarchy, and Enqueue Bugs
Why your filter doesn't fire, why custom post types disappear, the jQuery noConflict trap, and the template hierarchy details that decide which file actually renders.

WordPress runs ~40% of the web, which means it's the lingua franca of freelance web work. It's also full of subtle gotchas — most caused by hooking the wrong action, mistiming an enqueue, or misunderstanding the template hierarchy.
1. Actions vs Filters — the difference matters
| Action | Filter | |
|---|---|---|
| Purpose | "Do something at this point" | "Modify this value before it's used" |
| Returns | Nothing (or doesn't matter) | The modified value |
| Registered | add_action() | add_filter() |
| Triggered | do_action() | apply_filters($value) |
The most common bug: registering a callback with add_action when WordPress expected a filter (or vice versa). Filters that don't return a value silently break the chain — subsequent code gets null instead of the value it expected.
```php // Wrong: add_filter('the_content', function($content) { echo "<div>Promo</div>"; // outputs but doesn't return }); // Result: the_content() returns null, the post body disappears
// Right: add_filter('the_content', function($content) { return $content . "<div>Promo</div>"; }); ```
2. Hook priority and the $accepted_args parameter
``php
add_action('save_post', 'my_callback', 10, 2);
// ^ ^
// priority args count
``
Default priority is 10. Lower priority runs first. Multiple callbacks at the same priority run in registration order.
The $accepted_args defaults to 1 — if your callback expects more arguments, you must specify or they'll be missing. This is one of the most common "why is $post null in my callback?" bugs.
```php // Wrong: callback expects 2 args but only gets 1 add_action('save_post', function($post_id, $post) { // $post will be null });
// Right: add_action('save_post', function($post_id, $post) { // $post is set }, 10, 2); ```
3. Register custom post types on init, not anywhere else
```php // Wrong — runs too early, doesn't register register_post_type('book', [...]);
// Right add_action('init', function() { register_post_type('book', [...]); }); ```
A common bug: custom post types disappear from admin or stop appearing in queries because the registration didn't run on init. Same applies to taxonomies, sidebars, menu locations, and image sizes.
4. functions.php runs on EVERY request
functions.php is loaded for every page view — admin, frontend, AJAX, REST API, cron. If you put heavy code there without conditionals, you slow down the entire site.
```php // Bad — runs on every request $users = get_users(); foreach ($users as $u) { /* heavy work */ }
// Better — only when needed add_action('admin_init', function() { if (is_admin() && current_user_can('manage_options')) { $users = get_users(); // ... } }); ```
5. Template hierarchy — first match wins, in this order
For a single post of type book with slug my-book, WordPress checks:
single-book-my-book.phpsingle-book.phpsingle.phpsingular.phpindex.php(always exists)
Knowing this lets you target specific posts/categories/pages with template files instead of conditional logic.
The gotcha: index.php is the only file required in a theme. If you delete page.php thinking nothing uses it, pages will fall through to singular.php then index.php — which may look very different.
6. Child theme style.css doesn't load itself
``php
// In child theme functions.php
add_action('wp_enqueue_scripts', function() {
wp_enqueue_style('parent', get_template_directory_uri() . '/style.css');
wp_enqueue_style('child', get_stylesheet_uri(), ['parent']);
});
``
A common new-dev bug: making a child theme and not seeing the styles apply. WordPress doesn't auto-enqueue the child stylesheet — you have to do it yourself. And you must enqueue the parent stylesheet first if you want the child to override.
7. wp_enqueue_script in the head vs footer
``php
wp_enqueue_script(
'my-script',
'path/to/script.js',
['jquery'],
'1.0',
true // ← load in footer
);
``
The fifth parameter is $in_footer. Defaults to false (loads in <head>). For non-critical scripts, loading in footer is usually better for page speed.
The gotcha: if your script depends on the DOM and you load it in the head without defer/async or a DOMContentLoaded wrapper, it runs before elements exist.
8. jQuery noConflict mode — $ doesn't work
WordPress bundles jQuery in noConflict mode, so $ is reserved by other libraries (or undefined).
```javascript // Wrong — $ is not defined $('.button').click(...);
// Right — use the no-conflict wrapper (function($) { $('.button').click(...); })(jQuery);
// Or use jQuery directly jQuery('.button').click(...); ```
9. wp_localize_script — pass PHP data to JS without inline script tags
``php
wp_localize_script('my-script', 'myData', [
'ajax_url' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('my_action'),
'user_id' => get_current_user_id(),
]);
``
This creates a global JS variable myData before my-script loads. Use it instead of echo "<script>var myData = ...</script>" which is unsafe and hard to maintain.
10. Plugins load BEFORE themes
This matters when:
- A plugin defines a function with the same name as a theme function → fatal error
- A theme tries to override a plugin's defaults via constants or filters — too late
- A plugin enqueues a script that the theme tries to depend on — has to use the plugin's handle
Plugins also can't depend on theme functions at load time — they have to wait for after_setup_theme or init.
11. current_user_can() checks capabilities, not roles
```php // Right if (current_user_can('edit_posts')) { ... } if (current_user_can('manage_options')) { ... } // admin-only
// Wrong (works in some contexts but fragile) if (current_user_can('administrator')) { ... } ```
Capabilities are granular permissions. Roles are bundles of capabilities. Always check capabilities — they work even if someone renames roles or uses a custom role plugin.
12. the_excerpt() strips HTML; get_the_content() doesn't
``php
the_excerpt(); // strips shortcodes and HTML, applies filters
echo get_the_excerpt(); // same as above but returns instead of echoing
the_content(); // applies filters but keeps HTML
echo get_the_content(); // does NOT apply filters — raw post_content
``
The gotcha: get_the_content() doesn't apply the_content filters, so shortcodes don't expand and oEmbeds don't render. If you need filtered content as a string, use apply_filters('the_content', $post->post_content).
13. Don't query inside The Loop without wp_reset_postdata()
``php
while (have_posts()) : the_post();
// ...
$recent = new WP_Query(['posts_per_page' => 3]);
while ($recent->have_posts()) : $recent->the_post();
// ...
endwhile;
wp_reset_postdata(); // ← essential — restores outer $post
endwhile;
``
Forget the reset and the outer loop's $post is corrupted, leading to wrong titles/content on subsequent iterations.
Need help with a WordPress build or speed audit?
We've fixed WordPress sites from 8-second load times to under 2, untangled plugin conflicts that took down checkouts, and migrated dozens of sites between hosts without downtime. Get in touch if your WordPress site needs help.
Need Help With Your Website?
I fix these problems every day. Send me a message and I'll take a look.
Get Help Now