Custom WordPress Plugin Development: Structure, Hooks, and Release for Lightweight Plugins
1. When to write your own plugin
Every custom behavior in WordPress has to hook onto something. Whether you’re changing the sender email or adding a custom field, a few dozen lines of PHP will do it. The problem is that most people reach for the plugin repository first.
How to decide between installing a plugin and writing your own: if the requirement is just “change one fixed behavior” — change the default sender, add a snippet of custom JS, change the excerpt length — writing your own plugin is cleaner than installing a third-party one. For a general framework on choosing plugins, see WordPress Standalone Site Tech Stack.
The counterintuitive part: a functions.php that keeps growing isn’t a sign of skill, it’s a landmine for your next theme switch. When the theme updates or you change themes, functions.php gets overwritten and the code is gone. The right approach is to move custom logic into a standalone plugin and let the theme handle appearance only.
Naming has rules too. Use a prefix plus the feature for the plugin directory name, like crater-inquiry-source, so it won’t clash with official plugins. Prefix function names the same way, crater_register_source, to avoid conflicts with functions from other plugins.
Function name conflicts are the most common fatal error in WordPress plugins. If two plugins both define function register_source(), the one that loads second throws a blank screen. Every custom function needs a unique prefix.
| Requirement size | Recommended form | File count |
|---|---|---|
| 1 to 3 small changes | Single-file plugin | 1 PHP file |
| 3 to 5 related features | Single-file plugin + constants | 1 PHP file |
| 5 or more separate features | Directory-structure plugin | Main file + includes + assets |
| Needs an admin settings page | Directory-structure plugin | Add admin-page.php |
2. The minimal structure of a single-file plugin
A working WordPress plugin needs at minimum one PHP file with a plugin header comment at the top telling WP this is a plugin. Drop the file in wp-content/plugins/ and it shows up in the admin.
The plugin header comment has a fixed format: Plugin Name, Description, Version, Author. Without this comment, WP won’t recognize it as a plugin.
Example 1: Change the default sender email in 10 lines. Hook into the wp_mail_from and wp_mail_from_name filters and swap the default wordpress@ for noreply@example.com. No settings page needed.
This kind of filter-only plugin is the best place to start. No database, no admin pages, no cron jobs. If something breaks, deactivate the plugin and you’re back to normal.
If you need to store settings, use the options table that ships with WordPress. One row holds an array. Don’t create your own table. For simple settings, get_option and update_option are all you need.
| Hook type | Common hooks | Purpose |
|---|---|---|
| Filter | wp_mail_from、the_content | Modify output |
| Action | wp_head、admin_init | Insert code or run an action |
| Backend action | admin_menu、admin_init | Add a settings page, runs in admin only |
| Front-end action | wp_enqueue_scripts | Load CSS/JS, front end only |
Where you attach a hook is a performance decision. admin_init only fires in the admin, wp_head only on front-end page loads. Don’t attach admin-only logic to init, or every front-end request pays for it.
Load your own CSS and JS through the wp_enqueue_scripts hook, never by echoing directly in wp_head. Echoing directly causes scripts to load twice.
Example 3: The plugin needs to load a validation script on a form page. Register it with wp_enqueue_script, declare jquery as a dependency, and set the fifth parameter to true so the script loads in the footer without blocking the first render.
3. Activation, uninstall and data cleanup
If your plugin creates tables or writes options, you have to handle activation and uninstall. Create tables on activation, drop them on uninstall. Otherwise you delete the plugin and leave junk behind in the database.
register_activation_hook fires when the plugin is activated. Use it to create tables and write default options. uninstall.php fires when the plugin is deleted. Use it to clean up data. Note that deactivate and uninstall are not the same thing: deactivating keeps your data, deleting removes it.
Example 2: The plugin logs inquiry sources to a custom table. Create the table with dbDelta on activation, DROP TABLE on uninstall. If the user deactivates and reactivates, the data is still there. If they delete the plugin, the table goes with it.
| Lifespan | Hook | What to do |
|---|---|---|
| Activate plugin | register_activation_hook | Create tables, write default options |
| Deactivate plugin | register_deactivation_hook | Clear cron jobs, leave data alone |
| Delete plugin | uninstall.php | Drop tables, delete options |
| Every load | plugins_loaded | Define constants, load text domain |
One security note: in a custom plugin, anything that handles user input must go through $wpdb->prepare. Never concatenate SQL directly. For baseline user roles and admin permissions, see WordPress Security Baseline. For PHP version compatibility, see Upgrading PHP on Your Own Site.
4. Release and maintenance: private plugins don’t go in the official directory
A private plugin written for a single client site doesn’t need to be submitted to wordpress.org. Just package it as a zip and upload it in the admin, or git clone it straight into wp-content/mu-plugins, where MU plugins are enabled by default and can’t be accidentally deactivated.
Do version management. Bump the Version number in the plugin header with every change so updates show up in the admin. Don’t edit plugin files directly on the production site; make changes locally and upload them.
Several in-house plugins from the crater team follow this approach. See crater Tech Stack. They each solve a specific small problem, and none is more than two files.
| Release method | Use cases | Pros | Risk |
|---|---|---|---|
| Upload zip in admin | Deliver private plugin to client | Client can deactivate it | May be deleted by mistake |
| mu-plugins directory | Core logic must never stop | Enabled by default, can’t be deactivated by mistake | Updates are manual |
| Submit to wordpress.org | General public plugin | Automatic updates | Must pass review, write docs |
Don’t skip version management. Bump the Version in the plugin header with every change so the plugin list in the admin shows the “update available” marker.
Private plugins you maintain yourself have no official update channel. Use git to manage them and git pull straight to the server directory when deploying. Don’t edit files directly on the production site.
FAQ
When should you write your own plugin instead of installing a third-party one?
Can’t I just put the code in the theme’s functions.php?
How many lines of code does a minimal working WordPress plugin need?
If a plugin creates a data table, do you have to delete it yourself on uninstall?
Do private plugins need to be submitted to wordpress.org?
Why does my plugin code run on the front end every time?
Final thoughts
The value of a custom plugin isn’t showing off. It’s taking small, controllable pieces of logic back from third-party plugin libraries. A third-party plugin can change behavior with any update; your own code stays in your hands.
Three steps to get there: move the custom logic in functions.php into a single-file plugin, one piece at a time; if you created tables, write an uninstall routine to clean them up; and for private plugins, zip them or drop them in mu-plugins instead of submitting them to the official directory.
A common question: should I use Composer or a framework to write plugins? For a lightweight plugin built for a single client site, no.
Pulling in a framework means tens of megabytes of vendor files and dependencies to maintain on every update. If you can write it in 300 lines, a framework just makes life harder.
Composer and namespaces are worth it only when the plugin is meant to be a public product, needs unit tests, or will be worked on by several people.
One more note on security: when a plugin handles admin forms, you must run check_admin_referer for nonce verification to prevent cross-site request forgery. This is standard practice for WordPress admin forms; skip it and your admin endpoints are wide open.
When you output user input to a page, use escaping functions like esc_html and esc_attr instead of echoing it directly. Even configuration values you wrote yourself in the admin should be escaped before they hit the front end.
Finally, here’s a good order to start in. The first time you write a plugin, begin with pure filters like changing the sender or the excerpt length, and stay away from the database and admin pages.
Get through one full loop of writing code, uploading, activating, and seeing the result, then add features step by step. If you start with a plugin that has database tables and admin pages, hooks and the lifecycle will tie you in knots.
Smaller plugins are the right direction. Before adding any feature, ask yourself whether you can do it in twenty lines with existing hooks. If you can, don’t create a table. If you can, don’t add an admin page.
Keeping a plugin under 300 lines is doing right by yourself and by your client. Past that size, you probably should be using a mature third-party plugin.
One last reminder: don’t pile custom logic into your theme’s functions.php. Move it into a standalone plugin so the theme can be swapped freely and the client can maintain it long term.
One line worth remembering: if a 20-line hook can do the job, don’t install a 5,000-line third-party plugin. Less code means less that can go wrong.
