The Core Question
Both functions.php and a plugin execute PHP on every WordPress request. Mechanically, they're almost identical. The difference is what happens when you switch themes.
If you add a custom post type in functions.php and then switch themes, those post types vanish. The content still exists in the database, but WordPress no longer knows about it. That's a data integrity problem — and it's entirely avoidable.
functions.php.
What Belongs in functions.php
These are things that are genuinely about the theme — they make no sense without it:
- Theme support declarations —
add_theme_support('post-thumbnails'), title tag, HTML5 markup - Menu registration — menu locations are tied to theme layout
- Asset enqueueing — loading theme-specific CSS and JS
- Sidebar registration — widget areas defined by the theme's layout
- Template-level display logic — helpers that manipulate output only within theme templates
- Customizer settings — options that control theme appearance (colors, fonts)
What Belongs in a Plugin
These features survive a theme change — they belong to the site, not the theme:
- Custom post types and taxonomies — always a plugin
- Shortcodes — content uses them; a theme switch would break all content that contains them
- Custom database tables or meta
- Third-party API integrations — payment gateways, CRM connections, email services
- WP-CLI commands
- User roles and capabilities
- Cron jobs and background processing
- Any feature you'd want to enable/disable independently
The Must-Use Plugin Option
There's a third option that many developers overlook: must-use plugins (wp-content/mu-plugins/). Files in this directory:
- Load automatically — no activation step required
- Cannot be deactivated from the admin panel
- Load before regular plugins
- Are invisible to clients who shouldn't be disabling core functionality
// wp-content/mu-plugins/site-post-types.php
<?php
/**
* Plugin Name: Site Post Types
* Description: Registers custom post types for this installation.
*/
add_action( 'init', function() {
register_post_type( 'project', [
'labels' => [ 'name' => 'Projects', 'singular_name' => 'Project' ],
'public' => true,
'has_archive' => true,
'supports' => [ 'title', 'editor', 'thumbnail', 'custom-fields' ],
'show_in_rest' => true,
] );
} );
Must-use plugins are the right home for business-critical, always-on code that should never be deactivated accidentally.
The Site-Specific Plugin Pattern
On client projects, a common professional pattern is the site-specific plugin: a single plugin that contains all the custom post types, taxonomies, shortcodes, and integrations for one site. It's named after the client (e.g., acme-core/acme-core.php) and lives in the plugins directory.
This gives you:
- Theme-independence for all data structures
- A clear boundary between "theme" work and "site" work
- Version control isolation — you can update the theme without touching site functionality
- Easy handoff: a new developer immediately understands where business logic lives
Decision Flowchart
Run through these questions in order:
- Would this feature still be needed if the theme changed? → Yes: plugin. No: functions.php.
- Does it store or register data (post types, taxonomies, options)? → Always a plugin.
- Does it need to be activated/deactivated independently? → Plugin.
- Must it be always on and impossible to deactivate? → Must-use plugin.
- Is it purely about layout, appearance, or template output? → functions.php.
When in doubt, err toward a plugin. The overhead of creating one is minimal and the decoupling benefit is permanent.