CtrlK
BlogDocsLog inGet started
Tessl Logo

wordpress-plugin-starter

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

Quality

62%

Does it follow best practices?

Impact

Pending

No eval scenarios have been run

SecuritybySnyk

Advisory

Suggest reviewing before use

Optimize this skill with Tessl

npx tessl skill review --optimize ./backend-php/wordpress-plugin-starter/SKILL.md
SKILL.md
Quality
Evals
Security

WordPress Plugin Starter

Scaffold a WordPress 6.x plugin with custom post types, meta boxes, REST API endpoints, admin settings pages, asset enqueuing, and WP-CLI commands.

Prerequisites

  • PHP 8.1+ (8.3+ recommended)
  • WordPress 6.4+
  • MySQL 8.0+ or MariaDB 10.5+
  • Composer 2.x (for autoloading and dev dependencies)
  • WP-CLI 2.x
  • Node.js 20+ (for block editor assets, if needed)

Scaffold Command

# 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-polyfills

Project Structure

my-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/

Key Conventions

  • WordPress plugins do not use .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().
  • The main plugin file must have a valid plugin header comment block. This is how WordPress identifies the plugin.
  • Use activation/deactivation hooks for setup and teardown (flush rewrite rules, create DB tables, schedule cron).
  • All function names and global identifiers must be prefixed with the plugin slug (e.g., map_) or use namespaced classes to avoid collisions.
  • Use nonces for all form submissions and AJAX requests. Verify with wp_verify_nonce() and check_admin_referer().
  • Sanitize all input (sanitize_text_field(), absint(), wp_kses_post()). Escape all output (esc_html(), esc_attr(), esc_url(), wp_kses()).
  • Enqueue scripts and styles via wp_enqueue_scripts (frontend) and admin_enqueue_scripts (admin). Never hardcode <script> or <link> tags.
  • Use $wpdb->prepare() for all direct database queries. Prefer WP_Query and get_posts for post queries.
  • Text strings must be translatable via __(), _e(), esc_html__(), etc., using the plugin text domain.
  • uninstall.php handles complete cleanup when a user deletes the plugin from the admin.

Essential Patterns

Main Plugin File (Bootstrap)

<?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();
});

Plugin Bootstrap Class

<?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();
    }
}

Custom Post Type

<?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'],
        ]);
    }
}

Meta Box

<?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']));
        }
    }
}

Admin Settings Page (Settings API)

<?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
    }
}

REST API Custom Endpoint

<?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),
        ];
    }
}

WP-CLI Command

<?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 [];
    }
}

Hooks (Actions & Filters)

<?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);

Uninstall Cleanup

<?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');

First Steps After Scaffold

  1. Ensure WordPress is installed and running locally (e.g., via Local, MAMP, or Docker)
  2. Run composer install inside the plugin directory to set up autoloading
  3. Activate the plugin: wp plugin activate my-awesome-plugin
  4. Flush rewrite rules: wp rewrite flush
  5. Verify the REST API endpoint: curl http://localhost/wp-json/my-awesome-plugin/v1/events

Common Commands

# 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/

Integration Notes

  • Theme compatibility: Use 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.
  • Multisite: Wrap activation logic in is_multisite() checks. Use switch_to_blog() / restore_current_blog() for network-wide operations.
  • Caching: Use the WordPress Object Cache (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.
  • Security checklist: Nonces on all forms and AJAX. Capability checks (current_user_can) on all write operations. $wpdb->prepare() for raw SQL. Escape all output. Validate and sanitize all input.
  • AJAX (legacy): Register via 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.
  • Block Editor: For custom Gutenberg blocks, scaffold with npx @wordpress/create-block@latest map-event-block and register via register_block_type() in the plugin.
  • Composer autoloading: Run composer dump-autoload after adding new classes. The main plugin file requires vendor/autoload.php.
Repository
achreftlili/deep-dev-skills
Last updated
Created

Is this your skill?

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.