Scaffold a WordPress 6.x plugin with custom post types, meta boxes, REST API endpoints, admin settings pages, asset enqueuing, and WP-CLI commands.
62
75%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
Fix and improve this skill with Tessl
tessl review fix ./backend-php/wordpress-plugin-starter/SKILL.mdScaffold a WordPress 6.x plugin with custom post types, meta boxes, REST API endpoints, admin settings pages, asset enqueuing, and WP-CLI commands.
# Using WP-CLI to scaffold the plugin skeleton
wp scaffold plugin my-awesome-plugin --plugin_name="My Awesome Plugin" --plugin_author="Your Name" --plugin_author_uri="https://example.com" --plugin_uri="https://example.com/my-awesome-plugin"
# Or manually create and initialize with Composer autoloading
mkdir -p wp-content/plugins/my-awesome-plugin
cd wp-content/plugins/my-awesome-plugin
composer init --name="vendor/my-awesome-plugin" --type="wordpress-plugin" --autoload="psr-4:MyAwesomePlugin\\:src/"
composer require --dev phpunit/phpunit yoast/phpunit-polyfillsmy-awesome-plugin/
├── my-awesome-plugin.php # Main plugin file (header, hooks, bootstrap)
├── uninstall.php # Cleanup on plugin deletion
├── composer.json
├── src/
│ ├── Plugin.php # Central plugin class (singleton bootstrap)
│ ├── PostTypes/ # Custom post type registrations
│ │ └── EventPostType.php
│ ├── Metaboxes/ # Meta box definitions
│ │ └── EventDetailsMeta.php
│ ├── Admin/ # Admin pages and settings
│ │ └── SettingsPage.php
│ ├── RestApi/ # REST API custom endpoints
│ │ └── EventsEndpoint.php
│ ├── CLI/ # WP-CLI commands
│ │ └── SyncCommand.php
│ └── Frontend/ # Public-facing hooks and shortcodes
│ └── EventShortcode.php
├── assets/
│ ├── css/
│ │ ├── admin.css
│ │ └── frontend.css
│ └── js/
│ ├── admin.js
│ └── frontend.js
├── templates/ # PHP template partials
│ └── single-event.php
├── languages/ # Translation files (.pot, .po, .mo)
└── tests/
└── Unit/.env files. Configuration is handled via wp-config.php constants (e.g., define('MAP_API_KEY', 'value');). For plugin-specific settings, use the WordPress Settings API and get_option().map_) or use namespaced classes to avoid collisions.wp_verify_nonce() and check_admin_referer().sanitize_text_field(), absint(), wp_kses_post()). Escape all output (esc_html(), esc_attr(), esc_url(), wp_kses()).wp_enqueue_scripts (frontend) and admin_enqueue_scripts (admin). Never hardcode <script> or <link> tags.$wpdb->prepare() for all direct database queries. Prefer WP_Query and get_posts for post queries.__(), _e(), esc_html__(), etc., using the plugin text domain.uninstall.php handles complete cleanup when a user deletes the plugin from the admin.<?php
/**
* Plugin Name: My Awesome Plugin
* Plugin URI: https://example.com/my-awesome-plugin
* Description: A feature-rich WordPress plugin.
* Version: 1.0.0
* Author: Your Name
* Author URI: https://example.com
* License: GPL-2.0-or-later
* Text Domain: my-awesome-plugin
* Domain Path: /languages
* Requires at least: 6.4
* Requires PHP: 8.1
*/
defined('ABSPATH') || exit;
define('MAP_VERSION', '1.0.0');
define('MAP_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('MAP_PLUGIN_URL', plugin_dir_url(__FILE__));
define('MAP_PLUGIN_BASENAME', plugin_basename(__FILE__));
require_once MAP_PLUGIN_DIR . 'vendor/autoload.php';
// Activation
register_activation_hook(__FILE__, function (): void {
MyAwesomePlugin\Plugin::activate();
});
// Deactivation
register_deactivation_hook(__FILE__, function (): void {
MyAwesomePlugin\Plugin::deactivate();
});
// Bootstrap
add_action('plugins_loaded', function (): void {
MyAwesomePlugin\Plugin::instance()->init();
});<?php
// src/Plugin.php
namespace MyAwesomePlugin;
use MyAwesomePlugin\PostTypes\EventPostType;
use MyAwesomePlugin\Metaboxes\EventDetailsMeta;
use MyAwesomePlugin\Admin\SettingsPage;
use MyAwesomePlugin\RestApi\EventsEndpoint;
use MyAwesomePlugin\Frontend\EventShortcode;
final class Plugin
{
private static ?self $instance = null;
public static function instance(): self
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
private function __construct() {}
public function init(): void
{
load_plugin_textdomain('my-awesome-plugin', false, dirname(MAP_PLUGIN_BASENAME) . '/languages');
(new EventPostType())->register();
(new EventDetailsMeta())->register();
(new SettingsPage())->register();
(new EventsEndpoint())->register();
(new EventShortcode())->register();
add_action('wp_enqueue_scripts', [$this, 'enqueueFrontendAssets']);
add_action('admin_enqueue_scripts', [$this, 'enqueueAdminAssets']);
if (defined('WP_CLI') && WP_CLI) {
\WP_CLI::add_command('map sync', CLI\SyncCommand::class);
}
}
public function enqueueFrontendAssets(): void
{
wp_enqueue_style(
'map-frontend',
MAP_PLUGIN_URL . 'assets/css/frontend.css',
[],
MAP_VERSION,
);
wp_enqueue_script(
'map-frontend',
MAP_PLUGIN_URL . 'assets/js/frontend.js',
[],
MAP_VERSION,
true,
);
}
public function enqueueAdminAssets(string $hookSuffix): void
{
// Only load on our settings page
if ($hookSuffix !== 'settings_page_map-settings') {
return;
}
wp_enqueue_style(
'map-admin',
MAP_PLUGIN_URL . 'assets/css/admin.css',
[],
MAP_VERSION,
);
wp_enqueue_script(
'map-admin',
MAP_PLUGIN_URL . 'assets/js/admin.js',
['jquery'],
MAP_VERSION,
true,
);
}
public static function activate(): void
{
(new EventPostType())->register();
flush_rewrite_rules();
}
public static function deactivate(): void
{
flush_rewrite_rules();
}
}<?php
// src/PostTypes/EventPostType.php
namespace MyAwesomePlugin\PostTypes;
class EventPostType
{
public const SLUG = 'map_event';
public function register(): void
{
add_action('init', [$this, 'registerPostType']);
}
public function registerPostType(): void
{
$labels = [
'name' => __('Events', 'my-awesome-plugin'),
'singular_name' => __('Event', 'my-awesome-plugin'),
'add_new_item' => __('Add New Event', 'my-awesome-plugin'),
'edit_item' => __('Edit Event', 'my-awesome-plugin'),
'view_item' => __('View Event', 'my-awesome-plugin'),
'search_items' => __('Search Events', 'my-awesome-plugin'),
'not_found' => __('No events found.', 'my-awesome-plugin'),
];
register_post_type(self::SLUG, [
'labels' => $labels,
'public' => true,
'has_archive' => true,
'show_in_rest' => true,
'menu_icon' => 'dashicons-calendar-alt',
'supports' => ['title', 'editor', 'thumbnail', 'excerpt', 'custom-fields'],
'rewrite' => ['slug' => 'events'],
]);
}
}<?php
// src/Metaboxes/EventDetailsMeta.php
namespace MyAwesomePlugin\Metaboxes;
use MyAwesomePlugin\PostTypes\EventPostType;
class EventDetailsMeta
{
private const NONCE_ACTION = 'map_event_details_save';
private const NONCE_NAME = 'map_event_details_nonce';
public function register(): void
{
add_action('add_meta_boxes', [$this, 'addMetaBox']);
add_action('save_post_' . EventPostType::SLUG, [$this, 'save'], 10, 2);
}
public function addMetaBox(): void
{
add_meta_box(
'map_event_details',
__('Event Details', 'my-awesome-plugin'),
[$this, 'render'],
EventPostType::SLUG,
'normal',
'high',
);
}
public function render(\WP_Post $post): void
{
$eventDate = get_post_meta($post->ID, '_map_event_date', true);
$eventLocation = get_post_meta($post->ID, '_map_event_location', true);
wp_nonce_field(self::NONCE_ACTION, self::NONCE_NAME);
?>
<table class="form-table">
<tr>
<th><label for="map_event_date"><?php esc_html_e('Event Date', 'my-awesome-plugin'); ?></label></th>
<td><input type="date" id="map_event_date" name="map_event_date" value="<?php echo esc_attr($eventDate); ?>" class="regular-text"></td>
</tr>
<tr>
<th><label for="map_event_location"><?php esc_html_e('Location', 'my-awesome-plugin'); ?></label></th>
<td><input type="text" id="map_event_location" name="map_event_location" value="<?php echo esc_attr($eventLocation); ?>" class="regular-text"></td>
</tr>
</table>
<?php
}
public function save(int $postId, \WP_Post $post): void
{
if (!isset($_POST[self::NONCE_NAME])) {
return;
}
if (!wp_verify_nonce($_POST[self::NONCE_NAME], self::NONCE_ACTION)) {
return;
}
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
return;
}
if (!current_user_can('edit_post', $postId)) {
return;
}
if (isset($_POST['map_event_date'])) {
update_post_meta($postId, '_map_event_date', sanitize_text_field($_POST['map_event_date']));
}
if (isset($_POST['map_event_location'])) {
update_post_meta($postId, '_map_event_location', sanitize_text_field($_POST['map_event_location']));
}
}
}<?php
// src/Admin/SettingsPage.php
namespace MyAwesomePlugin\Admin;
class SettingsPage
{
private const OPTION_GROUP = 'map_settings_group';
private const OPTION_NAME = 'map_settings';
private const PAGE_SLUG = 'map-settings';
public function register(): void
{
add_action('admin_menu', [$this, 'addMenuPage']);
add_action('admin_init', [$this, 'registerSettings']);
}
public function addMenuPage(): void
{
add_options_page(
__('My Awesome Plugin Settings', 'my-awesome-plugin'),
__('My Awesome Plugin', 'my-awesome-plugin'),
'manage_options',
self::PAGE_SLUG,
[$this, 'renderPage'],
);
}
public function registerSettings(): void
{
register_setting(self::OPTION_GROUP, self::OPTION_NAME, [
'type' => 'array',
'sanitize_callback' => [$this, 'sanitize'],
'default' => [
'events_per_page' => 10,
'enable_archive' => true,
],
]);
add_settings_section(
'map_general_section',
__('General Settings', 'my-awesome-plugin'),
fn () => printf('<p>%s</p>', esc_html__('Configure general plugin options.', 'my-awesome-plugin')),
self::PAGE_SLUG,
);
add_settings_field(
'events_per_page',
__('Events Per Page', 'my-awesome-plugin'),
[$this, 'renderNumberField'],
self::PAGE_SLUG,
'map_general_section',
['label_for' => 'map_events_per_page'],
);
add_settings_field(
'enable_archive',
__('Enable Archive Page', 'my-awesome-plugin'),
[$this, 'renderCheckboxField'],
self::PAGE_SLUG,
'map_general_section',
['label_for' => 'map_enable_archive'],
);
}
public function sanitize(array $input): array
{
return [
'events_per_page' => absint($input['events_per_page'] ?? 10),
'enable_archive' => !empty($input['enable_archive']),
];
}
public function renderNumberField(): void
{
$options = get_option(self::OPTION_NAME);
printf(
'<input type="number" id="map_events_per_page" name="%s[events_per_page]" value="%d" min="1" max="100" class="small-text">',
esc_attr(self::OPTION_NAME),
absint($options['events_per_page'] ?? 10),
);
}
public function renderCheckboxField(): void
{
$options = get_option(self::OPTION_NAME);
printf(
'<input type="checkbox" id="map_enable_archive" name="%s[enable_archive]" value="1" %s>',
esc_attr(self::OPTION_NAME),
checked($options['enable_archive'] ?? true, true, false),
);
}
public function renderPage(): void
{
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(self::OPTION_GROUP);
do_settings_sections(self::PAGE_SLUG);
submit_button();
?>
</form>
</div>
<?php
}
}<?php
// src/RestApi/EventsEndpoint.php
namespace MyAwesomePlugin\RestApi;
use MyAwesomePlugin\PostTypes\EventPostType;
class EventsEndpoint
{
private const NAMESPACE = 'my-awesome-plugin/v1';
public function register(): void
{
add_action('rest_api_init', [$this, 'registerRoutes']);
}
public function registerRoutes(): void
{
register_rest_route(self::NAMESPACE, '/events', [
'methods' => \WP_REST_Server::READABLE,
'callback' => [$this, 'getEvents'],
'permission_callback' => '__return_true',
'args' => [
'per_page' => [
'default' => 10,
'sanitize_callback' => 'absint',
'validate_callback' => fn ($value) => is_numeric($value) && $value > 0 && $value <= 100,
],
],
]);
register_rest_route(self::NAMESPACE, '/events/(?P<id>\d+)', [
'methods' => \WP_REST_Server::READABLE,
'callback' => [$this, 'getEvent'],
'permission_callback' => '__return_true',
'args' => [
'id' => [
'sanitize_callback' => 'absint',
'validate_callback' => fn ($value) => is_numeric($value) && $value > 0,
],
],
]);
register_rest_route(self::NAMESPACE, '/events', [
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'createEvent'],
'permission_callback' => fn () => current_user_can('publish_posts'),
'args' => [
'title' => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'],
'content' => ['required' => true, 'sanitize_callback' => 'wp_kses_post'],
'date' => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'],
'location' => ['sanitize_callback' => 'sanitize_text_field'],
],
]);
}
public function getEvents(\WP_REST_Request $request): \WP_REST_Response
{
$query = new \WP_Query([
'post_type' => EventPostType::SLUG,
'posts_per_page' => $request->get_param('per_page'),
'post_status' => 'publish',
'orderby' => 'meta_value',
'meta_key' => '_map_event_date',
'order' => 'ASC',
]);
$events = array_map([$this, 'formatEvent'], $query->posts);
return new \WP_REST_Response([
'events' => $events,
'total' => $query->found_posts,
], 200);
}
public function getEvent(\WP_REST_Request $request): \WP_REST_Response
{
$post = get_post($request->get_param('id'));
if (!$post || $post->post_type !== EventPostType::SLUG) {
return new \WP_REST_Response(['message' => 'Event not found.'], 404);
}
return new \WP_REST_Response($this->formatEvent($post), 200);
}
public function createEvent(\WP_REST_Request $request): \WP_REST_Response
{
$postId = wp_insert_post([
'post_type' => EventPostType::SLUG,
'post_title' => $request->get_param('title'),
'post_content' => $request->get_param('content'),
'post_status' => 'publish',
], true);
if (is_wp_error($postId)) {
return new \WP_REST_Response(['message' => $postId->get_error_message()], 500);
}
update_post_meta($postId, '_map_event_date', $request->get_param('date'));
if ($location = $request->get_param('location')) {
update_post_meta($postId, '_map_event_location', $location);
}
return new \WP_REST_Response($this->formatEvent(get_post($postId)), 201);
}
private function formatEvent(\WP_Post $post): array
{
return [
'id' => $post->ID,
'title' => get_the_title($post),
'content' => apply_filters('the_content', $post->post_content),
'excerpt' => get_the_excerpt($post),
'date' => get_post_meta($post->ID, '_map_event_date', true),
'location' => get_post_meta($post->ID, '_map_event_location', true),
'link' => get_permalink($post),
];
}
}<?php
// src/CLI/SyncCommand.php
namespace MyAwesomePlugin\CLI;
use MyAwesomePlugin\PostTypes\EventPostType;
class SyncCommand
{
/**
* Sync events from an external API source.
*
* ## OPTIONS
*
* [--dry-run]
* : Preview changes without writing to the database.
*
* [--limit=<number>]
* : Maximum number of events to sync.
* ---
* default: 50
* ---
*
* ## EXAMPLES
*
* wp map sync
* wp map sync --dry-run
* wp map sync --limit=10
*
* @param array $args Positional arguments.
* @param array $assocArgs Associative arguments.
*/
public function __invoke(array $args, array $assocArgs): void
{
$dryRun = \WP_CLI\Utils\get_flag_value($assocArgs, 'dry-run', false);
$limit = (int) ($assocArgs['limit'] ?? 50);
\WP_CLI::log(sprintf('Syncing up to %d events...', $limit));
if ($dryRun) {
\WP_CLI::warning('Dry run mode — no changes will be made.');
}
// Replace with your actual sync logic
$externalEvents = $this->fetchExternalEvents($limit);
$synced = 0;
foreach ($externalEvents as $event) {
if ($dryRun) {
\WP_CLI::log(sprintf('Would sync: %s', $event['title']));
continue;
}
$postId = wp_insert_post([
'post_type' => EventPostType::SLUG,
'post_title' => sanitize_text_field($event['title']),
'post_content' => wp_kses_post($event['description']),
'post_status' => 'publish',
]);
if (!is_wp_error($postId)) {
update_post_meta($postId, '_map_event_date', sanitize_text_field($event['date']));
$synced++;
}
}
\WP_CLI::success(sprintf('Synced %d events.', $synced));
}
private function fetchExternalEvents(int $limit): array
{
// Stub: replace with actual HTTP request to external API
// Example: wp_remote_get('https://api.example.com/events?limit=' . $limit)
return [];
}
}<?php
// Examples of custom hooks for extensibility
// Fire a custom action when an event is created via REST API
do_action('map_event_created', $postId, $request);
// Allow filtering the event format before REST response
$eventData = apply_filters('map_format_event_response', $this->formatEvent($post), $post);
// Third-party code can hook into these:
add_action('map_event_created', function (int $postId, \WP_REST_Request $request): void {
// Send notification, sync to external service, etc.
}, 10, 2);
add_filter('map_format_event_response', function (array $data, \WP_Post $post): array {
$data['custom_field'] = get_post_meta($post->ID, '_custom_field', true);
return $data;
}, 10, 2);<?php
// uninstall.php
defined('WP_UNINSTALL_PLUGIN') || exit;
// Remove plugin options
delete_option('map_settings');
// Remove all custom post type posts and their meta
$posts = get_posts([
'post_type' => 'map_event',
'posts_per_page' => -1,
'post_status' => 'any',
'fields' => 'ids',
]);
foreach ($posts as $postId) {
wp_delete_post($postId, true);
}
// Clean up any scheduled cron events
wp_clear_scheduled_hook('map_daily_sync');composer install inside the plugin directory to set up autoloadingwp plugin activate my-awesome-pluginwp rewrite flushcurl http://localhost/wp-json/my-awesome-plugin/v1/events# WP-CLI basics
wp plugin activate my-awesome-plugin
wp plugin deactivate my-awesome-plugin
# Custom WP-CLI command
wp map sync
wp map sync --dry-run --limit=5
# Post type operations
wp post list --post_type=map_event
wp post create --post_type=map_event --post_title="Test Event" --post_status=publish
# Rewrite rules (after registering new post types)
wp rewrite flush
# Database
wp db query "SELECT * FROM wp_postmeta WHERE meta_key LIKE '_map_%'"
# Debugging
wp eval "var_dump(get_option('map_settings'));"
# Scaffold additional components
wp scaffold post-type map_event --plugin=my-awesome-plugin
wp scaffold taxonomy map_event_category --post_types=map_event --plugin=my-awesome-plugin
# i18n
wp i18n make-pot . languages/my-awesome-plugin.pot --domain=my-awesome-plugin
# Testing
vendor/bin/phpunit
vendor/bin/phpunit --filter=EventPostTypeTest
# Linting (WordPress coding standards)
composer require --dev wp-coding-standards/wpcs dealerdirect/phpcodesniffer-composer-installer
vendor/bin/phpcs --standard=WordPress src/
vendor/bin/phpcbf --standard=WordPress src/show_in_rest => true on custom post types to enable Gutenberg block editor support. Provide templates/single-event.php and document how themes can override it via single-map_event.php in the theme directory.is_multisite() checks. Use switch_to_blog() / restore_current_blog() for network-wide operations.wp_cache_get, wp_cache_set) or transients (get_transient, set_transient) for expensive queries. Invalidate on post save via save_post_{post_type} hook.current_user_can) on all write operations. $wpdb->prepare() for raw SQL. Escape all output. Validate and sanitize all input.wp_ajax_{action} and wp_ajax_nopriv_{action}. Pass the AJAX URL and nonce via wp_localize_script(). Prefer the REST API for new development.npx @wordpress/create-block@latest map-event-block and register via register_block_type() in the plugin.composer dump-autoload after adding new classes. The main plugin file requires vendor/autoload.php.181fcbc
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.