Scaffold a WordPress 6.x plugin with custom post types, meta boxes, REST API endpoints, admin settings pages, asset enqueuing, and WP-CLI commands.
67
62%
Does it follow best practices?
Impact
Pending
No eval scenarios have been run
Advisory
Suggest reviewing before use
Optimize this skill with Tessl
npx tessl skill review --optimize ./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.