Jay GajjarSenior Software Engineer

How to create a custom WordPress plugin: a step-by-step tutorial for 2026

How to create a custom WordPress plugin from scratch: header, activation, post type, settings, shortcode, block, REST route, security and coding standards.

By Jay Gajjar10 min read

If you want to know how to create a custom WordPress plugin, the short answer is: make a folder in wp-content/plugins, add a PHP file with a plugin header comment, and hook your code into WordPress with actions and filters. That is enough for WordPress to list and activate it. Making it production ready takes more: activation and uninstall routines, capability checks, nonces, sanitizing input, escaping output, translatable strings and properly enqueued assets. This WordPress plugin development tutorial builds a small but complete plugin step by step, tested against WordPress 7.1.

Quick answer: Create wp-content/plugins/my-plugin/my-plugin.php, start it with a header comment containing at least Plugin Name:, prefix every function, register features on the right hooks (init, admin_menu, rest_api_init), check current_user_can() before any privileged action, verify a nonce on every form, sanitize what comes in, escape what goes out, and add an uninstall.php that removes your data.

Custom plugin vs functions.php

Before writing a plugin, decide whether you need one. The rule I use: if the code should survive a theme change, it belongs in a plugin.

Put it in a plugin Put it in the (child) theme's functions.php
Custom post types and taxonomies Theme supports and image sizes
Shortcodes and blocks editors use in content Template tweaks and theme-specific filters
Integrations, REST routes, webhooks, cron jobs Enqueueing theme styles and fonts
Business rules (pricing, emails, user roles) Purely visual changes

If you register a custom post type in functions.php and the client switches themes next year, their content disappears from the admin. Shortcodes print as raw text. That is the classic custom plugin vs functions.php mistake. For small site-specific snippets that are not tied to the design, a tiny "site functionality" plugin, or a file in wp-content/mu-plugins (must-use plugins load automatically and cannot be deactivated from the admin), is cleaner than either.

How to create a custom WordPress plugin: what you need first

  • A local WordPress site on the current version (7.1 at the time of writing) and PHP 8.3, which is what WordPress.org recommends. Local by Flywheel, a Docker setup or npx @wordpress/env start (needs Docker) all work.
  • Debugging switched on in wp-config.php on your local site only:
define( 'WP_DEBUG', true );
define( 'WP_DEBUG_LOG', true );     // writes to wp-content/debug.log
define( 'WP_DEBUG_DISPLAY', false );
  • Query Monitor installed, to see PHP notices, hooks, queries and REST requests while you develop.
  • A code editor with PHP support, and Composer if you want coding standards checks (covered below).

Step 1: create the plugin folder and header

We will build "JG Testimonials": a Testimonials post type, a settings page, a shortcode, a block and a REST endpoint. Create this structure:

wp-content/plugins/jg-testimonials/
├── jg-testimonials.php
├── uninstall.php
├── assets/
│   ├── admin.js
│   └── testimonials.css
└── languages/

Everything in the steps below goes into jg-testimonials.php unless a step says otherwise.

The main file starts with the plugin header. WordPress reads these fields to show the plugin in the admin and to block activation on sites that do not meet the requirements. The full list is in the Plugin Handbook header requirements (opens in a new tab).

<?php
/**
 * Plugin Name:       JG Testimonials
 * Plugin URI:        https://example.com/jg-testimonials/
 * Description:       Testimonials post type with a shortcode, a block and a REST endpoint.
 * Version:           1.0.0
 * Requires at least: 6.8
 * Requires PHP:      8.1
 * Author:            Your Name
 * Author URI:        https://example.com/
 * License:           GPL-2.0-or-later
 * License URI:       https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain:       jg-testimonials
 * Domain Path:       /languages
 */

defined( 'ABSPATH' ) || exit;

define( 'JGT_VERSION', '1.0.0' );
define( 'JGT_FILE', __FILE__ );

Notes on the header:

  • Requires at least and Requires PHP should be the lowest versions you actually test on. WordPress refuses to activate the plugin on older setups instead of letting it crash.
  • Requires Plugins (available since WordPress 6.5) takes a comma-separated list of WordPress.org slugs, for example Requires Plugins: woocommerce. WordPress then will not activate your plugin until the dependency is installed and active. Use slugs only, not folder/file.php.
  • Update URI stops WordPress.org from offering an "update" to your private plugin if a public plugin with the same slug exists. Set it on private client plugins.
  • The defined( 'ABSPATH' ) || exit; line stops the file from running if someone requests it directly by URL.

Every function, option, post type and hook name gets the same prefix (jgt_ here). WordPress loads all plugins into one global space, and unprefixed names collide.

Step 2: activation, deactivation and uninstall

Activation runs once when the plugin is activated, deactivation when it is deactivated, and uninstall when the user deletes it. Each has a different job, explained in the activation and deactivation hooks guide (opens in a new tab).

function jgt_activate() {
	jgt_register_post_type(); // Register first so the rewrite rules include it.
	flush_rewrite_rules();
	add_option( 'jgt_settings', array( 'count' => 3 ) );
}
register_activation_hook( __FILE__, 'jgt_activate' );

function jgt_deactivate() {
	unregister_post_type( 'jgt_testimonial' );
	flush_rewrite_rules();
}
register_deactivation_hook( __FILE__, 'jgt_deactivate' );

Two common mistakes: calling flush_rewrite_rules() on every page load (it is slow and should only run on activation or deactivation), and deleting user data on deactivation. Deactivation is often temporary, for example while debugging a conflict. Data removal belongs in uninstall.php:

<?php
// uninstall.php: runs only when the plugin is deleted from the Plugins screen.
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
	exit;
}

delete_option( 'jgt_settings' );

// Deleting content is a business decision. Only do it if the client agrees:
// $ids = get_posts( array( 'post_type' => 'jgt_testimonial', 'post_status' => 'any', 'numberposts' => -1, 'fields' => 'ids' ) );
// foreach ( $ids as $id ) {
// 	wp_delete_post( $id, true );
// }

Step 3: register a custom post type

Custom post types register on init. Setting show_in_rest to true enables the block editor and the core REST endpoints for the post type.

function jgt_register_post_type() {
	register_post_type(
		'jgt_testimonial',
		array(
			'labels'       => array(
				'name'          => __( 'Testimonials', 'jg-testimonials' ),
				'singular_name' => __( 'Testimonial', 'jg-testimonials' ),
				'add_new_item'  => __( 'Add New Testimonial', 'jg-testimonials' ),
				'edit_item'     => __( 'Edit Testimonial', 'jg-testimonials' ),
			),
			'public'       => true,
			'has_archive'  => false,
			'rewrite'      => array( 'slug' => 'testimonials' ),
			'show_in_rest' => true,
			'menu_icon'    => 'dashicons-format-quote',
			'supports'     => array( 'title', 'editor', 'thumbnail' ),
		)
	);
}
add_action( 'init', 'jgt_register_post_type' );

The post type key must be 20 characters or fewer and should be prefixed. Here the title holds the client's name and the content holds the quote.

Step 4: add a settings page with the Settings API

The Settings API handles the form, the nonce, saving and the success message, so you write less security code yourself. The setting gets a sanitize_callback that runs before anything reaches the database.

function jgt_register_settings() {
	register_setting(
		'jgt_settings_group',
		'jgt_settings',
		array(
			'type'              => 'array',
			'sanitize_callback' => 'jgt_sanitize_settings',
			'default'           => array( 'count' => 3 ),
		)
	);

	add_settings_section( 'jgt_main', __( 'Display', 'jg-testimonials' ), '__return_false', 'jg-testimonials' );

	add_settings_field(
		'jgt_count',
		__( 'Testimonials to show', 'jg-testimonials' ),
		'jgt_render_count_field',
		'jg-testimonials',
		'jgt_main',
		array( 'label_for' => 'jgt_count' )
	);
}
add_action( 'admin_init', 'jgt_register_settings' );

function jgt_sanitize_settings( $input ) {
	$count = isset( $input['count'] ) ? absint( $input['count'] ) : 3;
	return array( 'count' => max( 1, min( $count, 20 ) ) );
}

function jgt_get_count() {
	$settings = get_option( 'jgt_settings', array( 'count' => 3 ) );
	return isset( $settings['count'] ) ? (int) $settings['count'] : 3;
}

function jgt_render_count_field() {
	printf(
		'<input type="number" id="jgt_count" name="jgt_settings[count]" value="%d" min="1" max="20" class="small-text">',
		absint( jgt_get_count() )
	);
}

function jgt_add_settings_page() {
	add_options_page(
		__( 'Testimonials settings', 'jg-testimonials' ),
		__( 'Testimonials', 'jg-testimonials' ),
		'manage_options',
		'jg-testimonials',
		'jgt_render_settings_page'
	);
}
add_action( 'admin_menu', 'jgt_add_settings_page' );

function jgt_render_settings_page() {
	if ( ! current_user_can( 'manage_options' ) ) {
		return;
	}
	?>
	<div class="wrap">
		<h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
		<form action="options.php" method="post">
			<?php
			settings_fields( 'jgt_settings_group' ); // Prints the nonce and hidden fields.
			do_settings_sections( 'jg-testimonials' );
			submit_button();
			?>
		</form>

		<form action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" method="post">
			<input type="hidden" name="action" value="jgt_reset">
			<?php
			wp_nonce_field( 'jgt_reset', 'jgt_reset_nonce' );
			submit_button( __( 'Reset to defaults', 'jg-testimonials' ), 'secondary' );
			?>
		</form>
	</div>
	<?php
}

The second form shows how to handle your own form when the Settings API does not fit: post to admin-post.php, check the capability, verify the nonce, act, redirect.

function jgt_handle_reset() {
	if ( ! current_user_can( 'manage_options' ) ) {
		wp_die( esc_html__( 'You are not allowed to do this.', 'jg-testimonials' ), 403 );
	}
	check_admin_referer( 'jgt_reset', 'jgt_reset_nonce' );

	update_option( 'jgt_settings', array( 'count' => 3 ) );

	wp_safe_redirect( admin_url( 'options-general.php?page=jg-testimonials' ) );
	exit;
}
add_action( 'admin_post_jgt_reset', 'jgt_handle_reset' );

A nonce proves the request came from your form in this user's session, which blocks cross-site request forgery. It does not prove the user is allowed to act. That is what current_user_can() is for, and you need both.

Step 5: output the testimonials with a shortcode and a block

One render function serves both the shortcode and the block. Notice that every value is escaped at the moment it is printed.

function jgt_register_assets() {
	wp_register_style(
		'jgt-testimonials',
		plugins_url( 'assets/testimonials.css', JGT_FILE ),
		array(),
		JGT_VERSION
	);
}
add_action( 'init', 'jgt_register_assets' );

function jgt_render_testimonials( $count ) {
	$query = new WP_Query(
		array(
			'post_type'      => 'jgt_testimonial',
			'posts_per_page' => $count,
			'no_found_rows'  => true,
		)
	);

	if ( ! $query->have_posts() ) {
		return '';
	}

	wp_enqueue_style( 'jgt-testimonials' ); // Loads only on pages that show testimonials.

	$html = '<div class="jgt-testimonials">';
	while ( $query->have_posts() ) {
		$query->the_post();
		$html .= sprintf(
			'<blockquote class="jgt-testimonial"><p>%s</p><cite>%s</cite></blockquote>',
			esc_html( wp_strip_all_tags( get_the_content() ) ),
			esc_html( get_the_title() )
		);
	}
	wp_reset_postdata();

	return $html . '</div>';
}

function jgt_shortcode( $atts ) {
	$atts = shortcode_atts(
		array( 'count' => jgt_get_count() ),
		$atts,
		'jgt_testimonials'
	);

	return jgt_render_testimonials( max( 1, min( absint( $atts['count'] ), 20 ) ) );
}
add_shortcode( 'jgt_testimonials', 'jgt_shortcode' );

Editors can now type [jgt_testimonials count="5"]. Shortcodes must return their output, never echo it, or the HTML appears in the wrong place on the page.

For the block, WordPress 7.0 added PHP-only block registration (opens in a new tab): set autoRegister in supports and provide a render_callback, and the block appears in the inserter with automatic sidebar controls for simple attributes, no JavaScript build step needed.

function jgt_register_block() {
	register_block_type(
		'jg-testimonials/list',
		array(
			'title'           => __( 'Testimonials', 'jg-testimonials' ),
			'attributes'      => array(
				'count' => array(
					'type'    => 'integer',
					'default' => 3,
					'label'   => __( 'Number to show', 'jg-testimonials' ),
				),
			),
			'supports'        => array( 'autoRegister' => true ),
			'render_callback' => function ( $attributes ) {
				$count = max( 1, min( absint( $attributes['count'] ?? 3 ), 20 ) );
				return sprintf(
					'<div %s>%s</div>',
					get_block_wrapper_attributes(),
					jgt_render_testimonials( $count )
				);
			},
		)
	);
}
add_action( 'init', 'jgt_register_block' );

On WordPress 6.8 and 6.9 the shortcode still works and the block simply does not show in the inserter, which is why the header can stay at Requires at least: 6.8. PHP-only blocks support string, number, integer and boolean attributes and no inner blocks. For richer editing (inline text, media pickers, nested blocks), scaffold a JavaScript block with npx @wordpress/create-block@latest, which sets up block.json and the @wordpress/scripts build.

Step 6: add a REST API route with a permission_callback

Custom REST routes register on rest_api_init. Every route needs a permission_callback; leaving it out triggers a _doing_it_wrong notice, and for public routes you set __return_true explicitly so the intent is clear. See the REST API handbook on custom endpoints (opens in a new tab).

function jgt_register_rest_routes() {
	register_rest_route(
		'jgt/v1',
		'/random',
		array(
			'methods'             => WP_REST_Server::READABLE,
			'callback'            => 'jgt_rest_random',
			'permission_callback' => '__return_true', // Public, read-only.
		)
	);

	register_rest_route(
		'jgt/v1',
		'/settings',
		array(
			'methods'             => WP_REST_Server::EDITABLE,
			'callback'            => 'jgt_rest_update_settings',
			'permission_callback' => function () {
				return current_user_can( 'manage_options' );
			},
			'args'                => array(
				'count' => array(
					'type'     => 'integer',
					'required' => true,
					'minimum'  => 1,
					'maximum'  => 20,
				),
			),
		)
	);
}
add_action( 'rest_api_init', 'jgt_register_rest_routes' );

function jgt_rest_random( WP_REST_Request $request ) {
	$posts = get_posts(
		array(
			'post_type'      => 'jgt_testimonial',
			'posts_per_page' => 1,
			'orderby'        => 'rand',
		)
	);

	if ( ! $posts ) {
		return new WP_Error( 'jgt_none', __( 'No testimonials yet.', 'jg-testimonials' ), array( 'status' => 404 ) );
	}

	return rest_ensure_response(
		array(
			'author' => get_the_title( $posts[0] ),
			'quote'  => wp_strip_all_tags( $posts[0]->post_content ),
		)
	);
}

function jgt_rest_update_settings( WP_REST_Request $request ) {
	update_option( 'jgt_settings', array( 'count' => $request->get_param( 'count' ) ) );
	return rest_ensure_response( array( 'count' => jgt_get_count() ) );
}

Because count declares a type, minimum and maximum, WordPress validates and sanitizes it against that schema before your callback runs, and returns a 400 error for bad input. Test both routes:

curl https://example.test/wp-json/jgt/v1/random

# Write route: authenticate with an application password (Users > Profile).
curl -X POST -u "admin:xxxx xxxx xxxx xxxx xxxx xxxx" \
  -d count=5 https://example.test/wp-json/jgt/v1/settings

From JavaScript in the admin, wp.apiFetch() sends the wp_rest nonce for you. If you use plain fetch(), pass a nonce from wp_create_nonce( 'wp_rest' ) in the X-WP-Nonce header, or WordPress treats the request as logged out.

Step 7: enqueue scripts and styles properly

Never print <script> or <link> tags directly. Register and enqueue assets so WordPress handles dependencies, versions and loading. Load them only where needed: the shortcode above enqueues its CSS only when testimonials render. For admin assets, check the page hook:

function jgt_admin_assets( $hook_suffix ) {
	if ( 'settings_page_jg-testimonials' !== $hook_suffix ) {
		return;
	}
	wp_enqueue_script(
		'jgt-admin',
		plugins_url( 'assets/admin.js', JGT_FILE ),
		array( 'wp-api-fetch' ),
		JGT_VERSION,
		array( 'strategy' => 'defer', 'in_footer' => true )
	);
}
add_action( 'admin_enqueue_scripts', 'jgt_admin_assets' );

Plugins that load their CSS and JavaScript on every page are one of the most common causes I find when I investigate why a WordPress site is slow.

Security basics: capabilities, nonces, sanitizing and escaping

Most plugin vulnerabilities come from skipping one of four checks. The Plugin Handbook's security section (opens in a new tab) covers each in detail; this is the version I keep in my head:

  1. Capabilities: check current_user_can() with the most specific capability that fits (manage_options, edit_posts, edit_post with an ID) before any privileged action. is_admin() only tells you the request is for an admin page, not who the user is.
  2. Nonces: wp_nonce_field() plus check_admin_referer() for forms, check_ajax_referer() for admin-ajax, wp_rest nonces for REST.
  3. Sanitize and validate input as early as possible.
  4. Escape output as late as possible, right where it is printed.
Data Sanitize on input Escape on output
Single-line text sanitize_text_field() esc_html()
Multi-line text sanitize_textarea_field() esc_textarea() in a textarea, esc_html() elsewhere
Integer absint() or (int) absint() or %d
URL esc_url_raw() esc_url()
Email sanitize_email() esc_html()
HTML attribute sanitize_text_field() esc_attr()
Keys and slugs sanitize_key() esc_attr()
Limited HTML wp_kses_post() wp_kses_post()

For database queries, use WordPress APIs (WP_Query, get_posts(), options and meta functions) where you can. When you must write SQL, always use $wpdb->prepare() with placeholders, never string concatenation.

Internationalization: make every string translatable

Wrap user-facing strings in translation functions with your text domain: __() returns, esc_html__() returns escaped, esc_html_e() echoes escaped, and _n() handles plurals. The text domain must match the plugin slug.

Plugins hosted on WordPress.org get their translations from translate.wordpress.org and loaded automatically. For a private plugin that ships its own .mo files in /languages, load them on init:

function jgt_load_textdomain() {
	load_plugin_textdomain( 'jg-testimonials', false, dirname( plugin_basename( JGT_FILE ) ) . '/languages' );
}
add_action( 'init', 'jgt_load_textdomain' );

Since WordPress 6.7, calling a translation function before init triggers a "translation loading was triggered too early" notice, so do not translate strings at the top level of your plugin file or on plugins_loaded. Generate the template with wp i18n make-pot . languages/jg-testimonials.pot.

Coding standards: PHPCS with WPCS and Plugin Check

The WordPress Coding Standards for PHP_CodeSniffer (opens in a new tab) catch formatting problems and, more importantly, missing escaping, unsanitized input and missing nonce checks. Install it per project:

composer config allow-plugins.dealerdirect/phpcodesniffer-composer-installer true
composer require --dev wp-coding-standards/wpcs:"^3.0"

Add a phpcs.xml.dist so the rules know your prefix, text domain and minimum WordPress version:

<?xml version="1.0"?>
<ruleset name="JG Testimonials">
	<file>.</file>
	<exclude-pattern>/vendor/*</exclude-pattern>
	<arg name="extensions" value="php"/>
	<config name="minimum_wp_version" value="6.8"/>
	<rule ref="WordPress"/>
	<rule ref="WordPress.WP.I18n">
		<properties>
			<property name="text_domain" type="array">
				<element value="jg-testimonials"/>
			</property>
		</properties>
	</rule>
	<rule ref="WordPress.NamingConventions.PrefixAllGlobals">
		<properties>
			<property name="prefixes" type="array">
				<element value="jgt"/>
			</property>
		</properties>
	</rule>
</ruleset>

Then run vendor/bin/phpcs to list problems and vendor/bin/phpcbf to fix formatting automatically. Before releasing, also run the official Plugin Check (opens in a new tab) plugin, which tests for the issues the WordPress.org review team flags, either from its admin screen or with wp plugin check jg-testimonials.

WordPress plugin boilerplate: do you need one?

A WordPress plugin boilerplate gives you a ready folder structure with classes for admin, public and internationalization code. The long-standing WordPress Plugin Boilerplate project is the best known. It is a reasonable starting point for a large plugin, but for small plugins its class layers add more files than features.

My usual choice depends on the plugin:

  • Create WordPress plugin from scratch, no boilerplate: best when you are learning, because you see every hook you register and nothing is hidden in framework classes.
  • Small, site-specific plugin: a single file like this tutorial, split into includes/ files once it passes a few hundred lines.
  • Block plugin: npx @wordpress/create-block@latest my-block, which scaffolds block.json, the build tooling and the PHP registration.
  • Plugin with tests: wp scaffold plugin my-plugin from WP-CLI, which adds PHPUnit configuration, then wp scaffold plugin-tests if you add tests later.
  • Large plugin: namespaces, Composer autoloading (PSR-4), a main plugin class that wires hooks, and separate classes for admin, REST and front-end code.

The structure matters less than the habits: prefix everything, register on the right hooks, check permissions, sanitize and escape.

When to hire a WordPress plugin developer

Knowing how to create a custom WordPress plugin is one skill; keeping it secure and compatible for years is another. Build it yourself when the plugin is small, the data is not sensitive, and you can afford to learn by fixing mistakes. Hire someone when:

  • The plugin handles payments, personal data, file uploads or user roles, where a security mistake is expensive.
  • It integrates with an external API (CRM, ERP, payment gateway, shipping), with webhooks, retries and error logging.
  • It extends WooCommerce checkout, orders or products, where performance and compatibility with updates matter.
  • It must keep working across years of WordPress and PHP updates without someone babysitting it.

That is the work I do in WordPress plugin development: I write the code myself, follow the standards above, and hand over clean, documented code you own. If the plugin is part of a bigger build, it fits into my broader WordPress development work.

Frequently asked questions

How do I create a WordPress plugin from scratch?

Create a folder in wp-content/plugins, add a PHP file with a header comment that includes Plugin Name:, and activate it from the Plugins screen. Then add your features with hooks, as in the steps above. No build tools are required for a PHP-only plugin.

Is it better to use a plugin or functions.php for custom code?

Use a plugin for anything that should keep working if the theme changes: post types, shortcodes, integrations and business logic. Use the child theme's functions.php only for code tied to that theme's design.

Do I need to know JavaScript to build a WordPress plugin?

Not for most plugins. Settings pages, post types, shortcodes, REST routes and, since WordPress 7.0, simple server-rendered blocks are all PHP. You need JavaScript (usually React through the WordPress packages) for interactive blocks and custom editor interfaces.

How do I make my plugin secure?

Check capabilities before privileged actions, verify nonces on forms and requests, sanitize and validate all input, escape all output, and use $wpdb->prepare() for any custom SQL. Run PHPCS with the WordPress standards and Plugin Check to catch what you miss.

Can I sell or distribute my custom plugin?

Yes. WordPress plugins are generally licensed under the GPL, and you can sell them or give them away. To list a plugin in the WordPress.org directory, it must be GPL-compatible and pass the plugin review, which is where the Plugin Check tool helps.

If you would rather have a custom plugin built and maintained by someone who does this daily, I can help through WordPress plugin development.