Published Updated on 6 min read
How to Build a WordPress Plugin: A Small FAQ Example
To build a WordPress plugin, create a folder in wp-content/plugins with a PHP file that has a plugin header, then hook your code into WordPress actions and filters. This guide builds a small FAQ plugin with a custom post type, a shortcode, a dynamic block, a meta box protected by a nonce, sanitized input, escaped output and activation hooks that flush rewrite rules.
To build a WordPress plugin, you create a folder in wp-content/plugins, add a PHP file with a plugin header, and attach your code to WordPress hooks. The rest is knowing which hooks to use and handling user input safely: nonces to confirm intent, capability checks for permission, sanitization on the way in and escaping on the way out.
In this post I build a small but complete FAQ plugin. It registers an FAQ post type, adds a meta box with a nonce, outputs a [faq_list] shortcode, provides a dynamic "FAQ List" block, and cleans up properly on activation, deactivation and uninstall. The code targets PHP 8.1+ and WordPress 6.4+ and was checked against WordPress 6.7 (Nov 2026).
What is a WordPress plugin?#
A WordPress plugin is a package of PHP code (plus optional CSS, JavaScript and assets) that adds features to a site by hooking into WordPress actions and filters, without editing core files or the theme. Actions run your code at a point in the request, such as init; filters let you change a value before WordPress uses it, such as the_content.
The reason to put a feature in a plugin rather than the theme's functions.php is ownership. FAQ content should survive a theme change. If the post type lives in the theme, switching themes hides every FAQ from the admin.
The Plugin Developer Handbook is the primary reference for everything below.
How do I structure a small WordPress plugin?#
Keep one main file for hooks and a folder per block. For this plugin:
simple-faq/
├── simple-faq.php # header, post type, meta box, shortcode, hooks
├── uninstall.php # runs when the plugin is deleted
└── blocks/
└── faq-list/
├── block.json # block metadata
├── index.js # editor script, no build step
├── index.asset.php # script dependencies
└── render.php # front-end outputThe plugin header and the post type come first. The ABSPATH check stops anyone from loading the file directly by URL.
<?php
/**
* Plugin Name: Simple FAQ
* Description: FAQ items with a [faq_list] shortcode and an FAQ List block.
* Version: 1.0.0
* Requires at least: 6.4
* Requires PHP: 8.1
* Author: Sami Ullah
* License: GPL-2.0-or-later
* Text Domain: simple-faq
*/
declare(strict_types=1);
if (!defined('ABSPATH')) {
exit;
}
const SFAQ_VERSION = '1.0.0';
const SFAQ_POST_TYPE = 'sfaq_item';
function sfaq_register_post_type(): void
{
register_post_type(SFAQ_POST_TYPE, [
'labels' => [
'name' => __('FAQs', 'simple-faq'),
'singular_name' => __('FAQ', 'simple-faq'),
],
'public' => true,
'has_archive' => false,
'rewrite' => ['slug' => 'faq'],
'show_in_rest' => true,
'menu_icon' => 'dashicons-editor-help',
'supports' => ['title', 'editor', 'page-attributes'],
]);
}
add_action('init', 'sfaq_register_post_type');The title holds the question, the editor holds the answer, and page-attributes gives each FAQ an "Order" number editors can use to sort them. show_in_rest turns on the block editor for this post type.
What should activation and deactivation hooks do?#
Activation hooks prepare the site once when the plugin is switched on; deactivation hooks undo temporary changes when it is switched off. For a plugin with a public post type, the main job is flushing rewrite rules so /faq/question-slug/ URLs work straight away.
register_activation_hook(__FILE__, function (): void {
sfaq_register_post_type(); // rules must exist before flushing
flush_rewrite_rules();
add_option('sfaq_version', SFAQ_VERSION);
});
register_deactivation_hook(__FILE__, function (): void {
unregister_post_type(SFAQ_POST_TYPE);
flush_rewrite_rules();
});Deactivation is not deletion. Users deactivate plugins to debug conflicts and expect their data back when they reactivate. Permanent clean-up belongs in uninstall.php, which WordPress runs only when the user deletes the plugin:
<?php
// uninstall.php
if (!defined('WP_UNINSTALL_PLUGIN')) {
exit;
}
delete_option('sfaq_version');
// FAQ posts are kept on purpose. Delete them here only if users expect that.The three hooks compare like this:
| Hook | Runs when | Use it for | Do not |
|---|---|---|---|
register_activation_hook | Plugin is activated | Default options, custom tables, flush rewrites | Output HTML or redirect |
register_deactivation_hook | Plugin is deactivated | Clear scheduled events, flush rewrites | Delete user data |
uninstall.php | Plugin is deleted from the admin | Remove options, tables, and data the user agreed to lose | Assume plugin functions are loaded |
How do I use nonces and sanitization in a WordPress plugin?#
Add a nonce field to every form you render, then on save verify the nonce, check the user's capability, and sanitize each value before storing it. Each check covers a different attack, so skipping one leaves a gap.
A nonce is a token WordPress generates for a specific user, action name and time window (about 24 hours by default). The nonces documentation explains that it protects against cross-site request forgery: another site tricking a logged-in admin's browser into submitting your form.
This meta box lets editors put each FAQ into a group, such as shipping or returns, so the shortcode can show one group per page.
add_action('add_meta_boxes', function (): void {
add_meta_box(
'sfaq_group',
__('FAQ group', 'simple-faq'),
'sfaq_render_meta_box',
SFAQ_POST_TYPE,
'side'
);
});
function sfaq_render_meta_box(WP_Post $post): void
{
wp_nonce_field('sfaq_save_group', 'sfaq_nonce');
$group = (string) get_post_meta($post->ID, '_sfaq_group', true);
printf(
'<label for="sfaq_group">%s</label><input type="text" id="sfaq_group" name="sfaq_group" value="%s" class="widefat">',
esc_html__('Group slug (for example: shipping)', 'simple-faq'),
esc_attr($group)
);
}
add_action('save_post_' . SFAQ_POST_TYPE, function (int $post_id): void {
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
return;
}
$nonce = isset($_POST['sfaq_nonce']) ? sanitize_text_field(wp_unslash($_POST['sfaq_nonce'])) : '';
if (!wp_verify_nonce($nonce, 'sfaq_save_group')) {
return;
}
if (!current_user_can('edit_post', $post_id)) {
return;
}
$group = isset($_POST['sfaq_group']) ? sanitize_key(wp_unslash($_POST['sfaq_group'])) : '';
if ($group === '') {
delete_post_meta($post_id, '_sfaq_group');
} else {
update_post_meta($post_id, '_sfaq_group', $group);
}
});A few details that are easy to miss:
wp_unslash()comes before sanitizing, because WordPress adds slashes to$_POSTfor historical reasons.sanitize_key()lowercases the value and keeps only letters, numbers, dashes and underscores, which suits a group slug. For free text usesanitize_text_field(), for numbersabsint(), for URLsesc_url_raw().- The meta key starts with an underscore, so it does not show in the default Custom Fields panel where editors could change it without the nonce flow.
save_post_{post_type}fires only for this post type, so there is no need to check the type by hand.
The most common plugin vulnerabilities come from skipping exactly one of these steps. An AJAX or admin-post.php handler that checks a nonce but not current_user_can() lets any logged-in subscriber change settings. A value sanitized on save but echoed later without esc_html() or esc_attr() becomes stored cross-site scripting once another code path writes to the same field. Treat sanitizing and escaping as two separate jobs, and do both every time.
How do I add a shortcode to a WordPress plugin?#
Register it with add_shortcode(), merge user attributes with defaults through shortcode_atts(), and return (never echo) the HTML. I keep the actual rendering in a separate function so the block can call it too.
function sfaq_render_list(int $limit = 10, string $group = ''): string
{
$args = [
'post_type' => SFAQ_POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => max(1, min($limit, 50)),
'orderby' => ['menu_order' => 'ASC', 'title' => 'ASC'],
'no_found_rows' => true,
];
if ($group !== '') {
$args['meta_key'] = '_sfaq_group';
$args['meta_value'] = $group;
}
$query = new WP_Query($args);
if (!$query->have_posts()) {
return '';
}
$html = '<div class="sfaq-list">';
foreach ($query->posts as $faq) {
$html .= sprintf(
'<details class="sfaq-item"><summary>%s</summary><div class="sfaq-answer">%s</div></details>',
esc_html(get_the_title($faq)),
wp_kses_post(wpautop($faq->post_content))
);
}
return $html . '</div>';
}
add_shortcode('faq_list', function ($atts): string {
$atts = shortcode_atts(
['limit' => '10', 'group' => ''],
(array) $atts,
'faq_list'
);
return sfaq_render_list(absint($atts['limit']), sanitize_key($atts['group']));
});Editors can now write [faq_list group="shipping" limit="5"] anywhere shortcodes run. Shortcode attributes are user input too, which is why limit goes through absint() and is capped at 50, and group goes through sanitize_key().
The output uses native details and summary elements, which are keyboard accessible without any JavaScript.
How do I turn the shortcode into a block?#
Register a dynamic block from a block.json file and point its render property at a PHP file that calls the same function. A dynamic block saves only its attributes in post content and builds the HTML on each request, so FAQ edits show up everywhere the block is used.
add_action('init', function (): void {
register_block_type(__DIR__ . '/blocks/faq-list');
});{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "simple-faq/faq-list",
"title": "FAQ List",
"category": "widgets",
"icon": "editor-help",
"description": "Shows FAQ items as an accessible accordion.",
"textdomain": "simple-faq",
"attributes": {
"limit": { "type": "number", "default": 10 },
"group": { "type": "string", "default": "" }
},
"supports": { "html": false },
"editorScript": "file:./index.js",
"render": "file:./render.php"
}The render property arrived in WordPress 6.1, and the block metadata reference lists every other field. Inside render.php, WordPress provides $attributes:
<?php
// blocks/faq-list/render.php
$limit = isset($attributes['limit']) ? absint($attributes['limit']) : 10;
$group = isset($attributes['group']) ? sanitize_key((string) $attributes['group']) : '';
$list = sfaq_render_list($limit, $group);
if ($list === '') {
return;
}
?>
<div <?php echo get_block_wrapper_attributes(); ?>>
<?php echo $list; // escaped inside sfaq_render_list() ?>
</div>The editor script needs no build step. It registers the block on the client and previews it with the server-rendered HTML:
// blocks/faq-list/index.js
(function (blocks, element, blockEditor, ServerSideRender) {
const el = element.createElement;
blocks.registerBlockType("simple-faq/faq-list", {
edit: function (props) {
return el(
"div",
blockEditor.useBlockProps(),
el(ServerSideRender, { block: "simple-faq/faq-list", attributes: props.attributes })
);
},
save: function () {
return null;
},
});
})(window.wp.blocks, window.wp.element, window.wp.blockEditor, window.wp.serverSideRender);<?php
// blocks/faq-list/index.asset.php
return [
'dependencies' => ['wp-blocks', 'wp-element', 'wp-block-editor', 'wp-server-side-render'],
'version' => '1.0.0',
];For a block with sidebar settings and a real editing UI, move to @wordpress/scripts and JSX. For a list block like this one, the plain script is enough.
Should the plugin go on WordPress.org or stay private?#
Keep a plugin private when it exists for one site or one client, and publish it on WordPress.org only when other people can use it without your help. Publishing means a public review, support requests in the forums and a promise to keep it working with new WordPress releases.
For a private plugin, keep it in the project's Git repository and deploy it with the rest of the site, or install it as a must-use plugin in wp-content/mu-plugins if editors should never be able to deactivate it. Must-use plugins load automatically, but they do not run activation hooks, so any set-up such as flushing rewrite rules has to happen another way, for example by checking a stored version number on init.
If you do publish, read the plugin guidelines in the handbook first. The review team checks for the same things this post covers: escaping, sanitization, nonces, capability checks and a unique prefix on every function, option and post type.
How do I test the plugin before shipping it?#
Test it on a clean local site with WP_DEBUG on, so notices and deprecations show up instead of hiding in a log. My checklist for a plugin this size:
- Activate it and visit an FAQ URL to confirm the rewrite flush worked.
- Save an FAQ with a group, then edit the nonce field in browser dev tools and save again. The group must not change.
- Log in as a Contributor and confirm they cannot edit other users' FAQs.
- Put
[faq_list]and the block on the same page and compare output. - Enter a question containing
<script>and confirm it renders as text. - Deactivate, reactivate, then delete, and check the options table.
The same structure scales to bigger plugins: my WordPress booking plugin case study adds a booking request form, an admin list of requests and email alerts on top of an FAQ block, and you can try it in the WordPress booking plugin demo. You can see more of my WordPress work on the WordPress skill page, and if you are starting from a static design rather than a plugin, read HTML to WordPress with ACF.
What should you do first?#
Write the render function before anything else, and make it return escaped HTML from a few hard-coded arguments. Once that one function works, the shortcode and the block are thin wrappers around it, and every security check you add later protects both. Then add the nonce, capability check and sanitization to the save handler before you show the plugin to anyone.
Frequently asked questions
What is the minimum code needed for a WordPress plugin?
A single PHP file in its own folder under wp-content/plugins with a header comment containing at least Plugin Name. WordPress reads that header to list the plugin on the Plugins screen. Everything else, such as post types, shortcodes and blocks, is added by hooking functions into actions and filters. Add an ABSPATH check at the top so the file cannot be run directly from a browser.
What is a WordPress nonce and is it enough for security?
A nonce is a token tied to the current user, an action name and a time window, used to confirm a request came from a form or link your site generated. It protects against cross-site request forgery. It is not enough on its own, because it does not prove permission. Always pair wp_verify_nonce with current_user_can, and still sanitize every input value.
Should I use a shortcode or a block in a new plugin?
Offer both when the output is the same. Blocks give editors a visual preview and settings in the block editor, while shortcodes still work in classic editor content, widgets, page builders and theme templates through do_shortcode. Put the rendering in one PHP function and call it from the shortcode callback and the block render file, so there is a single place to fix bugs.
Why do I need to flush rewrite rules on plugin activation?
When a plugin registers a public custom post type, WordPress needs new rewrite rules so URLs like /faq/shipping-times/ resolve. Rewrite rules are cached in the database and only rebuilt when flushed. Flushing once on activation, after registering the post type, makes the new URLs work immediately. Never flush on every page load, because rebuilding the rules is slow and writes to the database.
Where I used this
- Case study: Custom WordPress Booking Plugin · ▶ live demo
- Case study: WooCommerce Store Customization · ▶ live demo
- Skill: WordPress