When custom code belongs in a plugin
Every WordPress site accumulates small pieces of custom behaviour: a post type, a shortcode, an integration with a booking API, a tweak to how WooCommerce emails render, a scheduled import. The default place people put them is functions.php, and the default result is a theme that cannot be changed without losing the site's business logic.
The rule: if the code would still be needed with a different theme, it is not theme code. It goes in a plugin. A site-specific plugin, written for this one client, is the correct home for that logic, and writing one well is not much more work than writing it badly.
Structure
One plugin, named for the client or the site, in wp-content/plugins/acme-site/. Inside:
acme-site/
acme-site.php # header, constants, bootstrap
includes/
class-post-types.php # CPTs and taxonomies
class-integrations.php
class-admin.php # settings page, admin tweaks
functions.php # small helpers
assets/
admin.css
acf-json/ # ACF field group exports
composer.json # if using autoloading or libraries
readme.mdThe main file declares the plugin header, defines a version constant, and requires the includes. Each include is a class or a set of functions with a single responsibility, hooked on init or plugins_loaded. No logic in the main file beyond bootstrapping.
Namespace it (namespace Acme\Site;) or prefix everything (acme_). Collisions with other plugins' function names are a real failure mode.
What goes in it
- Post types and taxonomies, so they survive theme changes and appear in the admin regardless of theme.
- ACF field groups, exported to
acf-json/in the plugin so they are versioned and load from disk. - Integrations: API clients for the booking system, CRM, payment provider, with credentials in options or environment, never hardcoded.
- Scheduled tasks: registered with Action Scheduler or WP-Cron, with the task functions here.
- Business rules: WooCommerce pricing tweaks, email template filters, form handlers.
- Admin adjustments: dashboard widgets removed, menu items renamed, an "Acme settings" page.
- Shortcodes and blocks that render data, with their PHP here and their block registration here too. Presentation CSS may still live in the theme; behaviour lives here.
What stays in the theme: templates, styles, template parts, design tokens, and anything purely about how things look.
Must-use or regular?
A must-use plugin (wp-content/mu-plugins/) cannot be deactivated from the admin and loads before regular plugins. Use it when the site must not function without the code (post types the theme renders, critical integrations) and the client should not be able to switch it off by accident. Use a regular plugin when the client or another developer may legitimately need to deactivate it for debugging. For most business sites, a regular plugin with a clear name and a description that says "do not deactivate; contains the site's content types and integrations" is the pragmatic choice.
Settings
Anything the client might change (an API key, a toggle, an email address for notifications) belongs in a settings page, not in code. The Settings API or a small ACF options page. Store secrets in options only if the site has no environment variable mechanism; prefer constants in wp-config.php read by the plugin, with the settings page showing "configured" rather than the value.
Versioning and updates
Version the plugin in its header and in a constant. Keep it in the site's Git repository alongside the theme. When it changes, the version bumps and a changelog line is added to readme.md. If the plugin creates database tables or needs migrations, store a schema version in an option and run upgrades on plugins_loaded when the stored version is behind.
This is not distributed through the plugin directory and does not need an update server. It deploys with the site. Say so in the readme so nobody wonders why it never shows an update.
Coding standards, briefly
Escape output (esc_html, esc_attr, esc_url), sanitise input, use nonces on forms, check capabilities before doing anything privileged. Prepared statements for any direct SQL. defined('ABSPATH') || exit; at the top of every file. Run it through PHP_CodeSniffer with the WordPress ruleset once. These are the things a security scan or a future developer will judge the plugin by.
The readme
The handover document for the plugin, in the plugin:
- What it does, in five lines.
- Each include and its responsibility.
- Every hook it adds and every hook it filters.
- Every option it stores.
- Every external service it talks to, with where the credentials live.
- Any scheduled tasks and their intervals.
- How to run it locally.
- The changelog.
A developer who has never seen the site should be able to read this in ten minutes and know what will break if the plugin is deactivated.
Handover
The plugin is in the repository. The readme is current. The client's handover document lists it under "site plugin: contains the content types and integrations; do not remove". The client owns it, in writing, like everything else. If another developer takes the site on, they have a self-contained, documented unit rather than a functions.php archaeology project.
Where this sits
A site-specific plugin is part of nearly every WordPress build we deliver, and creating one to receive the logic dug out of functions.php is a common first task when we inherit a site onto a care plan. It is a small discipline. It is also the difference between a theme change being a design job and being a rescue.