WordPress

Writing a WordPress Plugin for One Client, Properly

When a client needs behaviour no plugin provides, a small bespoke plugin is the right home. How to structure it, keep it out of the theme, and hand it over.

5 min read
WordPressPlugin DevelopmentHandover
913 words5 min read

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:

Text
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.md

The 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.

All writingHire me for this