Skip to content

WordPress Framework Guide

Applies to: WordPress 6.0+, PHP 8.0+, Plugin Development, Theme Development, REST API, Block Editor (Gutenberg)


Overview

WordPress is a content management system (CMS) that powers over 40% of the web. This guide covers modern WordPress development including plugin development, theme development, REST API, and the Block Editor (Gutenberg).

Use WordPress when you need: - Content management system - Blog or publishing platform - E-commerce (with WooCommerce) - Custom applications with familiar admin UI - Rapid prototyping with existing ecosystem

Consider alternatives when: - Building pure API backend (use Laravel/Symfony) - High-performance requirements (consider headless) - Complex business logic applications - Microservices architecture


Project Structure

Plugin Structure

my-plugin/
├── my-plugin.php              # Main plugin file
├── includes/
│   ├── class-plugin.php       # Main plugin class
│   ├── class-activator.php    # Activation hooks
│   ├── class-deactivator.php  # Deactivation hooks
│   ├── admin/
│   │   ├── class-admin.php    # Admin functionality
│   │   └── partials/          # Admin templates
│   ├── public/
│   │   ├── class-public.php   # Public functionality
│   │   └── partials/          # Public templates
│   ├── api/
│   │   └── class-rest-api.php # REST API endpoints
│   └── blocks/
│       └── my-block/          # Gutenberg blocks
├── assets/
│   ├── css/
│   ├── js/
│   └── images/
├── languages/                 # Translation files
├── templates/                 # Template files
├── tests/
│   └── phpunit/
├── composer.json
├── package.json
└── readme.txt                 # WordPress.org readme

Theme Structure

my-theme/
├── style.css                  # Theme metadata
├── functions.php              # Theme functions
├── index.php                  # Main template
├── header.php                 # Header template
├── footer.php                 # Footer template
├── sidebar.php                # Sidebar template
├── single.php                 # Single post template
├── page.php                   # Page template
├── archive.php                # Archive template
├── 404.php                    # 404 template
├── search.php                 # Search results
├── comments.php               # Comments template
├── inc/
│   ├── customizer.php         # Customizer settings
│   ├── template-functions.php # Template helpers
│   └── template-hooks.php     # Action/filter hooks
├── template-parts/
│   ├── content.php
│   ├── content-single.php
│   └── content-page.php
├── assets/
│   ├── css/
│   ├── js/
│   └── images/
├── blocks/                    # Block theme patterns
├── patterns/                  # Block patterns
├── parts/                     # Template parts (FSE)
├── templates/                 # Block templates (FSE)
└── theme.json                 # Theme configuration

Plugin Development

Main Plugin File

<?php
/**
 * Plugin Name: My Plugin
 * Plugin URI: https://example.com/my-plugin
 * Description: A modern WordPress plugin
 * Version: 1.0.0
 * Requires at least: 6.0
 * Requires PHP: 8.0
 * Author: Your Name
 * Author URI: https://example.com
 * License: GPL v2 or later
 * License URI: https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain: my-plugin
 * Domain Path: /languages
 *
 * @package MyPlugin
 */

declare(strict_types=1);

namespace MyPlugin;

// Prevent direct access
if (!defined('ABSPATH')) {
    exit;
}

// Plugin constants
define('MY_PLUGIN_VERSION', '1.0.0');
define('MY_PLUGIN_PATH', plugin_dir_path(__FILE__));
define('MY_PLUGIN_URL', plugin_dir_url(__FILE__));
define('MY_PLUGIN_BASENAME', plugin_basename(__FILE__));

// Autoloader
require_once MY_PLUGIN_PATH . 'vendor/autoload.php';

// Activation/Deactivation hooks
register_activation_hook(__FILE__, [Activator::class, 'activate']);
register_deactivation_hook(__FILE__, [Deactivator::class, 'deactivate']);

// Initialize plugin
add_action('plugins_loaded', function (): void {
    Plugin::getInstance()->init();
});

Main Plugin Class

<?php

declare(strict_types=1);

namespace MyPlugin;

final class Plugin
{
    private static ?self $instance = null;

    public static function getInstance(): self
    {
        if (self::$instance === null) {
            self::$instance = new self();
        }
        return self::$instance;
    }

    private function __construct() {}

    public function init(): void
    {
        // Load translations
        load_plugin_textdomain(
            'my-plugin',
            false,
            dirname(MY_PLUGIN_BASENAME) . '/languages'
        );

        // Initialize components
        $this->initAdmin();
        $this->initPublic();
        $this->initApi();
        $this->initBlocks();
    }

    private function initAdmin(): void
    {
        if (is_admin()) {
            new Admin\Admin();
        }
    }

    private function initPublic(): void
    {
        new Frontend\Frontend();
    }

    private function initApi(): void
    {
        new Api\RestApi();
    }

    private function initBlocks(): void
    {
        new Blocks\BlockManager();
    }
}

Custom Post Type

<?php

declare(strict_types=1);

namespace MyPlugin\PostTypes;

final class BookPostType
{
    public const POST_TYPE = 'book';

    public function __construct()
    {
        add_action('init', [$this, 'register']);
        add_action('init', [$this, 'registerTaxonomies']);
    }

    public function register(): void
    {
        $labels = [
            'name'               => __('Books', 'my-plugin'),
            'singular_name'      => __('Book', 'my-plugin'),
            'menu_name'          => __('Books', 'my-plugin'),
            'add_new'            => __('Add New', 'my-plugin'),
            'add_new_item'       => __('Add New Book', 'my-plugin'),
            'edit_item'          => __('Edit Book', 'my-plugin'),
            'new_item'           => __('New Book', 'my-plugin'),
            'view_item'          => __('View Book', 'my-plugin'),
            'search_items'       => __('Search Books', 'my-plugin'),
            'not_found'          => __('No books found', 'my-plugin'),
            'not_found_in_trash' => __('No books found in trash', 'my-plugin'),
        ];

        $args = [
            'labels'              => $labels,
            'public'              => true,
            'publicly_queryable'  => true,
            'show_ui'             => true,
            'show_in_menu'        => true,
            'show_in_rest'        => true, // Enable Gutenberg & REST API
            'query_var'           => true,
            'rewrite'             => ['slug' => 'books'],
            'capability_type'     => 'post',
            'has_archive'         => true,
            'hierarchical'        => false,
            'menu_position'       => 20,
            'menu_icon'           => 'dashicons-book',
            'supports'            => [
                'title',
                'editor',
                'author',
                'thumbnail',
                'excerpt',
                'comments',
                'custom-fields',
            ],
            'template'            => [
                ['core/paragraph', ['placeholder' => 'Add book description...']],
            ],
        ];

        register_post_type(self::POST_TYPE, $args);
    }

    public function registerTaxonomies(): void
    {
        // Genre taxonomy
        register_taxonomy('genre', self::POST_TYPE, [
            'labels' => [
                'name'          => __('Genres', 'my-plugin'),
                'singular_name' => __('Genre', 'my-plugin'),
            ],
            'public'            => true,
            'hierarchical'      => true,
            'show_in_rest'      => true,
            'show_admin_column' => true,
            'rewrite'           => ['slug' => 'genre'],
        ]);

        // Author taxonomy (non-hierarchical like tags)
        register_taxonomy('book_author', self::POST_TYPE, [
            'labels' => [
                'name'          => __('Authors', 'my-plugin'),
                'singular_name' => __('Author', 'my-plugin'),
            ],
            'public'            => true,
            'hierarchical'      => false,
            'show_in_rest'      => true,
            'show_admin_column' => true,
            'rewrite'           => ['slug' => 'book-author'],
        ]);
    }
}

Meta Boxes and Custom Fields

<?php

declare(strict_types=1);

namespace MyPlugin\Admin;

final class BookMetaBox
{
    public function __construct()
    {
        add_action('add_meta_boxes', [$this, 'addMetaBox']);
        add_action('save_post_book', [$this, 'saveMetaBox'], 10, 2);
        add_action('init', [$this, 'registerMeta']);
    }

    public function registerMeta(): void
    {
        // Register meta for REST API
        register_post_meta('book', '_book_isbn', [
            'type'              => 'string',
            'single'            => true,
            'show_in_rest'      => true,
            'sanitize_callback' => 'sanitize_text_field',
            'auth_callback'     => fn() => current_user_can('edit_posts'),
        ]);

        register_post_meta('book', '_book_price', [
            'type'              => 'number',
            'single'            => true,
            'show_in_rest'      => true,
            'sanitize_callback' => fn($value) => floatval($value),
        ]);

        register_post_meta('book', '_book_publish_date', [
            'type'              => 'string',
            'single'            => true,
            'show_in_rest'      => true,
            'sanitize_callback' => 'sanitize_text_field',
        ]);
    }

    public function addMetaBox(): void
    {
        add_meta_box(
            'book_details',
            __('Book Details', 'my-plugin'),
            [$this, 'renderMetaBox'],
            'book',
            'normal',
            'high'
        );
    }

    public function renderMetaBox(\WP_Post $post): void
    {
        wp_nonce_field('book_meta_box', 'book_meta_box_nonce');

        $isbn = get_post_meta($post->ID, '_book_isbn', true);
        $price = get_post_meta($post->ID, '_book_price', true);
        $publishDate = get_post_meta($post->ID, '_book_publish_date', true);

        ?>
        <table class="form-table">
            <tr>
                <th><label for="book_isbn"><?php esc_html_e('ISBN', 'my-plugin'); ?></label></th>
                <td>
                    <input type="text" id="book_isbn" name="book_isbn"
                           value="<?php echo esc_attr($isbn); ?>" class="regular-text">
                </td>
            </tr>
            <tr>
                <th><label for="book_price"><?php esc_html_e('Price', 'my-plugin'); ?></label></th>
                <td>
                    <input type="number" id="book_price" name="book_price" step="0.01"
                           value="<?php echo esc_attr($price); ?>" class="small-text">
                </td>
            </tr>
            <tr>
                <th><label for="book_publish_date"><?php esc_html_e('Publish Date', 'my-plugin'); ?></label></th>
                <td>
                    <input type="date" id="book_publish_date" name="book_publish_date"
                           value="<?php echo esc_attr($publishDate); ?>">
                </td>
            </tr>
        </table>
        <?php
    }

    public function saveMetaBox(int $postId, \WP_Post $post): void
    {
        // Verify nonce
        if (!isset($_POST['book_meta_box_nonce']) ||
            !wp_verify_nonce($_POST['book_meta_box_nonce'], 'book_meta_box')) {
            return;
        }

        // Check autosave
        if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
            return;
        }

        // Check permissions
        if (!current_user_can('edit_post', $postId)) {
            return;
        }

        // Save meta
        if (isset($_POST['book_isbn'])) {
            update_post_meta($postId, '_book_isbn', sanitize_text_field($_POST['book_isbn']));
        }

        if (isset($_POST['book_price'])) {
            update_post_meta($postId, '_book_price', floatval($_POST['book_price']));
        }

        if (isset($_POST['book_publish_date'])) {
            update_post_meta($postId, '_book_publish_date', sanitize_text_field($_POST['book_publish_date']));
        }
    }
}

REST API

Custom Endpoints

<?php

declare(strict_types=1);

namespace MyPlugin\Api;

use WP_REST_Controller;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
use WP_Error;

final class BooksController extends WP_REST_Controller
{
    protected $namespace = 'my-plugin/v1';
    protected $rest_base = 'books';

    public function __construct()
    {
        add_action('rest_api_init', [$this, 'registerRoutes']);
    }

    public function registerRoutes(): void
    {
        register_rest_route($this->namespace, '/' . $this->rest_base, [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [$this, 'getItems'],
                'permission_callback' => [$this, 'getItemsPermissionsCheck'],
                'args'                => $this->getCollectionParams(),
            ],
            [
                'methods'             => WP_REST_Server::CREATABLE,
                'callback'            => [$this, 'createItem'],
                'permission_callback' => [$this, 'createItemPermissionsCheck'],
                'args'                => $this->getEndpointArgsForItemSchema(WP_REST_Server::CREATABLE),
            ],
            'schema' => [$this, 'getPublicItemSchema'],
        ]);

        register_rest_route($this->namespace, '/' . $this->rest_base . '/(?P<id>[\d]+)', [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [$this, 'getItem'],
                'permission_callback' => [$this, 'getItemPermissionsCheck'],
                'args'                => [
                    'id' => [
                        'validate_callback' => fn($param) => is_numeric($param),
                    ],
                ],
            ],
            [
                'methods'             => WP_REST_Server::EDITABLE,
                'callback'            => [$this, 'updateItem'],
                'permission_callback' => [$this, 'updateItemPermissionsCheck'],
                'args'                => $this->getEndpointArgsForItemSchema(WP_REST_Server::EDITABLE),
            ],
            [
                'methods'             => WP_REST_Server::DELETABLE,
                'callback'            => [$this, 'deleteItem'],
                'permission_callback' => [$this, 'deleteItemPermissionsCheck'],
            ],
            'schema' => [$this, 'getPublicItemSchema'],
        ]);
    }

    public function getItems(WP_REST_Request $request): WP_REST_Response
    {
        $args = [
            'post_type'      => 'book',
            'posts_per_page' => $request->get_param('per_page') ?? 10,
            'paged'          => $request->get_param('page') ?? 1,
            'orderby'        => $request->get_param('orderby') ?? 'date',
            'order'          => $request->get_param('order') ?? 'DESC',
        ];

        // Filter by genre
        if ($genre = $request->get_param('genre')) {
            $args['tax_query'] = [
                [
                    'taxonomy' => 'genre',
                    'field'    => 'slug',
                    'terms'    => $genre,
                ],
            ];
        }

        // Search
        if ($search = $request->get_param('search')) {
            $args['s'] = $search;
        }

        $query = new \WP_Query($args);
        $books = [];

        foreach ($query->posts as $post) {
            $books[] = $this->prepareItemForResponse($post, $request);
        }

        $response = new WP_REST_Response($books, 200);

        // Add pagination headers
        $response->header('X-WP-Total', $query->found_posts);
        $response->header('X-WP-TotalPages', $query->max_num_pages);

        return $response;
    }

    public function getItem(WP_REST_Request $request): WP_REST_Response|WP_Error
    {
        $id = (int) $request->get_param('id');
        $post = get_post($id);

        if (!$post || $post->post_type !== 'book') {
            return new WP_Error(
                'rest_book_not_found',
                __('Book not found.', 'my-plugin'),
                ['status' => 404]
            );
        }

        return new WP_REST_Response($this->prepareItemForResponse($post, $request), 200);
    }

    public function createItem(WP_REST_Request $request): WP_REST_Response|WP_Error
    {
        $postData = [
            'post_type'    => 'book',
            'post_title'   => sanitize_text_field($request->get_param('title')),
            'post_content' => wp_kses_post($request->get_param('content') ?? ''),
            'post_status'  => $request->get_param('status') ?? 'draft',
            'post_author'  => get_current_user_id(),
        ];

        $postId = wp_insert_post($postData, true);

        if (is_wp_error($postId)) {
            return $postId;
        }

        // Save meta
        if ($isbn = $request->get_param('isbn')) {
            update_post_meta($postId, '_book_isbn', sanitize_text_field($isbn));
        }

        if ($price = $request->get_param('price')) {
            update_post_meta($postId, '_book_price', floatval($price));
        }

        // Set genres
        if ($genres = $request->get_param('genres')) {
            wp_set_object_terms($postId, $genres, 'genre');
        }

        $post = get_post($postId);

        return new WP_REST_Response($this->prepareItemForResponse($post, $request), 201);
    }

    public function updateItem(WP_REST_Request $request): WP_REST_Response|WP_Error
    {
        $id = (int) $request->get_param('id');
        $post = get_post($id);

        if (!$post || $post->post_type !== 'book') {
            return new WP_Error(
                'rest_book_not_found',
                __('Book not found.', 'my-plugin'),
                ['status' => 404]
            );
        }

        $postData = ['ID' => $id];

        if ($title = $request->get_param('title')) {
            $postData['post_title'] = sanitize_text_field($title);
        }

        if ($content = $request->get_param('content')) {
            $postData['post_content'] = wp_kses_post($content);
        }

        $result = wp_update_post($postData, true);

        if (is_wp_error($result)) {
            return $result;
        }

        // Update meta
        if ($request->has_param('isbn')) {
            update_post_meta($id, '_book_isbn', sanitize_text_field($request->get_param('isbn')));
        }

        if ($request->has_param('price')) {
            update_post_meta($id, '_book_price', floatval($request->get_param('price')));
        }

        $post = get_post($id);

        return new WP_REST_Response($this->prepareItemForResponse($post, $request), 200);
    }

    public function deleteItem(WP_REST_Request $request): WP_REST_Response|WP_Error
    {
        $id = (int) $request->get_param('id');
        $post = get_post($id);

        if (!$post || $post->post_type !== 'book') {
            return new WP_Error(
                'rest_book_not_found',
                __('Book not found.', 'my-plugin'),
                ['status' => 404]
            );
        }

        $force = $request->get_param('force') ?? false;

        if ($force) {
            wp_delete_post($id, true);
        } else {
            wp_trash_post($id);
        }

        return new WP_REST_Response(null, 204);
    }

    private function prepareItemForResponse(\WP_Post $post, WP_REST_Request $request): array
    {
        return [
            'id'           => $post->ID,
            'title'        => $post->post_title,
            'content'      => $post->post_content,
            'excerpt'      => $post->post_excerpt,
            'status'       => $post->post_status,
            'date'         => $post->post_date,
            'modified'     => $post->post_modified,
            'author'       => (int) $post->post_author,
            'featured_image' => get_post_thumbnail_id($post->ID) ?: null,
            'isbn'         => get_post_meta($post->ID, '_book_isbn', true),
            'price'        => (float) get_post_meta($post->ID, '_book_price', true),
            'publish_date' => get_post_meta($post->ID, '_book_publish_date', true),
            'genres'       => wp_get_object_terms($post->ID, 'genre', ['fields' => 'names']),
            'authors'      => wp_get_object_terms($post->ID, 'book_author', ['fields' => 'names']),
            '_links'       => [
                'self' => rest_url("{$this->namespace}/{$this->rest_base}/{$post->ID}"),
            ],
        ];
    }

    // Permission callbacks
    public function getItemsPermissionsCheck(WP_REST_Request $request): bool
    {
        return true; // Public access
    }

    public function getItemPermissionsCheck(WP_REST_Request $request): bool
    {
        return true;
    }

    public function createItemPermissionsCheck(WP_REST_Request $request): bool
    {
        return current_user_can('publish_posts');
    }

    public function updateItemPermissionsCheck(WP_REST_Request $request): bool
    {
        $id = (int) $request->get_param('id');
        return current_user_can('edit_post', $id);
    }

    public function deleteItemPermissionsCheck(WP_REST_Request $request): bool
    {
        $id = (int) $request->get_param('id');
        return current_user_can('delete_post', $id);
    }

    public function getItemSchema(): array
    {
        return [
            '$schema'    => 'http://json-schema.org/draft-04/schema#',
            'title'      => 'book',
            'type'       => 'object',
            'properties' => [
                'id' => [
                    'type'        => 'integer',
                    'readonly'    => true,
                ],
                'title' => [
                    'type'        => 'string',
                    'required'    => true,
                ],
                'content' => [
                    'type'        => 'string',
                ],
                'isbn' => [
                    'type'        => 'string',
                ],
                'price' => [
                    'type'        => 'number',
                ],
                'genres' => [
                    'type'        => 'array',
                    'items'       => ['type' => 'string'],
                ],
            ],
        ];
    }
}

Gutenberg Blocks

Block Registration

<?php

declare(strict_types=1);

namespace MyPlugin\Blocks;

final class BlockManager
{
    public function __construct()
    {
        add_action('init', [$this, 'registerBlocks']);
    }

    public function registerBlocks(): void
    {
        // Register block with block.json
        register_block_type(MY_PLUGIN_PATH . 'blocks/book-card');

        // Dynamic block with PHP render callback
        register_block_type('my-plugin/featured-books', [
            'render_callback' => [$this, 'renderFeaturedBooks'],
            'attributes'      => [
                'count' => [
                    'type'    => 'number',
                    'default' => 3,
                ],
                'genre' => [
                    'type'    => 'string',
                    'default' => '',
                ],
            ],
        ]);
    }

    public function renderFeaturedBooks(array $attributes): string
    {
        $args = [
            'post_type'      => 'book',
            'posts_per_page' => $attributes['count'],
            'orderby'        => 'date',
            'order'          => 'DESC',
        ];

        if (!empty($attributes['genre'])) {
            $args['tax_query'] = [
                [
                    'taxonomy' => 'genre',
                    'field'    => 'slug',
                    'terms'    => $attributes['genre'],
                ],
            ];
        }

        $query = new \WP_Query($args);

        if (!$query->have_posts()) {
            return '<p>' . esc_html__('No books found.', 'my-plugin') . '</p>';
        }

        ob_start();
        ?>
        <div class="wp-block-my-plugin-featured-books">
            <?php while ($query->have_posts()) : $query->the_post(); ?>
                <article class="book-card">
                    <?php if (has_post_thumbnail()) : ?>
                        <div class="book-card__image">
                            <?php the_post_thumbnail('medium'); ?>
                        </div>
                    <?php endif; ?>
                    <div class="book-card__content">
                        <h3 class="book-card__title">
                            <a href="<?php the_permalink(); ?>"><?php the_title(); ?></a>
                        </h3>
                        <?php the_excerpt(); ?>
                    </div>
                </article>
            <?php endwhile; ?>
        </div>
        <?php
        wp_reset_postdata();

        return ob_get_clean();
    }
}

block.json

{
    "$schema": "https://schemas.wp.org/trunk/block.json",
    "apiVersion": 3,
    "name": "my-plugin/book-card",
    "version": "1.0.0",
    "title": "Book Card",
    "category": "widgets",
    "icon": "book",
    "description": "Display a book card.",
    "supports": {
        "html": false,
        "align": ["wide", "full"],
        "color": {
            "background": true,
            "text": true
        },
        "spacing": {
            "margin": true,
            "padding": true
        }
    },
    "attributes": {
        "bookId": {
            "type": "number"
        },
        "showImage": {
            "type": "boolean",
            "default": true
        },
        "showExcerpt": {
            "type": "boolean",
            "default": true
        }
    },
    "textdomain": "my-plugin",
    "editorScript": "file:./index.js",
    "editorStyle": "file:./index.css",
    "style": "file:./style-index.css",
    "render": "file:./render.php"
}

Block JavaScript (index.js)

import { registerBlockType } from '@wordpress/blocks';
import { useBlockProps, InspectorControls } from '@wordpress/block-editor';
import { PanelBody, ToggleControl, ComboboxControl } from '@wordpress/components';
import { useSelect } from '@wordpress/data';
import { __ } from '@wordpress/i18n';
import ServerSideRender from '@wordpress/server-side-render';

import './editor.scss';
import './style.scss';

registerBlockType('my-plugin/book-card', {
    edit: ({ attributes, setAttributes }) => {
        const { bookId, showImage, showExcerpt } = attributes;
        const blockProps = useBlockProps();

        // Fetch books for selection
        const books = useSelect((select) => {
            return select('core').getEntityRecords('postType', 'book', {
                per_page: 100,
                _fields: ['id', 'title'],
            });
        }, []);

        const bookOptions = (books || []).map((book) => ({
            value: book.id,
            label: book.title.rendered,
        }));

        return (
            <>
                <InspectorControls>
                    <PanelBody title={__('Book Settings', 'my-plugin')}>
                        <ComboboxControl
                            label={__('Select Book', 'my-plugin')}
                            value={bookId}
                            options={bookOptions}
                            onChange={(value) => setAttributes({ bookId: parseInt(value, 10) })}
                        />
                        <ToggleControl
                            label={__('Show Featured Image', 'my-plugin')}
                            checked={showImage}
                            onChange={(value) => setAttributes({ showImage: value })}
                        />
                        <ToggleControl
                            label={__('Show Excerpt', 'my-plugin')}
                            checked={showExcerpt}
                            onChange={(value) => setAttributes({ showExcerpt: value })}
                        />
                    </PanelBody>
                </InspectorControls>
                <div {...blockProps}>
                    {bookId ? (
                        <ServerSideRender
                            block="my-plugin/book-card"
                            attributes={attributes}
                        />
                    ) : (
                        <p>{__('Please select a book.', 'my-plugin')}</p>
                    )}
                </div>
            </>
        );
    },
    save: () => null, // Dynamic block - rendered on server
});

Hooks and Filters

Common Actions

<?php

declare(strict_types=1);

namespace MyPlugin;

final class Hooks
{
    public function __construct()
    {
        // Actions
        add_action('init', [$this, 'onInit']);
        add_action('wp_enqueue_scripts', [$this, 'enqueueAssets']);
        add_action('admin_enqueue_scripts', [$this, 'enqueueAdminAssets']);
        add_action('wp_head', [$this, 'addMetaTags']);
        add_action('save_post', [$this, 'onSavePost'], 10, 3);
        add_action('user_register', [$this, 'onUserRegister']);
        add_action('wp_ajax_my_action', [$this, 'handleAjax']);
        add_action('wp_ajax_nopriv_my_action', [$this, 'handleAjax']);

        // Filters
        add_filter('the_content', [$this, 'filterContent']);
        add_filter('the_title', [$this, 'filterTitle'], 10, 2);
        add_filter('excerpt_length', [$this, 'customExcerptLength']);
        add_filter('post_class', [$this, 'addPostClasses'], 10, 3);
    }

    public function onInit(): void
    {
        // Initialize functionality
    }

    public function enqueueAssets(): void
    {
        wp_enqueue_style(
            'my-plugin-style',
            MY_PLUGIN_URL . 'assets/css/public.css',
            [],
            MY_PLUGIN_VERSION
        );

        wp_enqueue_script(
            'my-plugin-script',
            MY_PLUGIN_URL . 'assets/js/public.js',
            ['jquery'],
            MY_PLUGIN_VERSION,
            true
        );

        wp_localize_script('my-plugin-script', 'MyPluginData', [
            'ajaxUrl' => admin_url('admin-ajax.php'),
            'nonce'   => wp_create_nonce('my_plugin_nonce'),
            'strings' => [
                'loading' => __('Loading...', 'my-plugin'),
                'error'   => __('An error occurred.', 'my-plugin'),
            ],
        ]);
    }

    public function enqueueAdminAssets(string $hook): void
    {
        // Only load on specific admin pages
        if ($hook !== 'post.php' && $hook !== 'post-new.php') {
            return;
        }

        wp_enqueue_style(
            'my-plugin-admin-style',
            MY_PLUGIN_URL . 'assets/css/admin.css',
            [],
            MY_PLUGIN_VERSION
        );
    }

    public function onSavePost(int $postId, \WP_Post $post, bool $update): void
    {
        // Skip autosave
        if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
            return;
        }

        // Skip revisions
        if (wp_is_post_revision($postId)) {
            return;
        }

        // Custom save logic
        if ($post->post_type === 'book' && $update) {
            do_action('my_plugin_book_updated', $postId, $post);
        }
    }

    public function filterContent(string $content): string
    {
        if (!is_singular('book')) {
            return $content;
        }

        // Add book info before content
        $bookInfo = $this->getBookInfoHtml(get_the_ID());
        return $bookInfo . $content;
    }

    public function handleAjax(): void
    {
        check_ajax_referer('my_plugin_nonce', 'nonce');

        $action = sanitize_text_field($_POST['custom_action'] ?? '');

        switch ($action) {
            case 'get_books':
                $this->ajaxGetBooks();
                break;
            default:
                wp_send_json_error(['message' => __('Invalid action.', 'my-plugin')]);
        }
    }

    private function ajaxGetBooks(): void
    {
        $books = get_posts([
            'post_type'      => 'book',
            'posts_per_page' => 10,
        ]);

        $data = array_map(fn($book) => [
            'id'    => $book->ID,
            'title' => $book->post_title,
            'url'   => get_permalink($book),
        ], $books);

        wp_send_json_success($data);
    }
}

Database Operations

Custom Tables

<?php

declare(strict_types=1);

namespace MyPlugin;

final class Database
{
    public static function createTables(): void
    {
        global $wpdb;

        $charsetCollate = $wpdb->get_charset_collate();
        $tableName = $wpdb->prefix . 'book_reviews';

        $sql = "CREATE TABLE {$tableName} (
            id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
            book_id bigint(20) unsigned NOT NULL,
            user_id bigint(20) unsigned NOT NULL,
            rating tinyint(1) unsigned NOT NULL,
            review text NOT NULL,
            status varchar(20) NOT NULL DEFAULT 'pending',
            created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
            updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
            PRIMARY KEY (id),
            KEY book_id (book_id),
            KEY user_id (user_id),
            KEY status (status)
        ) {$charsetCollate};";

        require_once ABSPATH . 'wp-admin/includes/upgrade.php';
        dbDelta($sql);

        update_option('my_plugin_db_version', MY_PLUGIN_VERSION);
    }

    public static function dropTables(): void
    {
        global $wpdb;

        $tableName = $wpdb->prefix . 'book_reviews';
        $wpdb->query("DROP TABLE IF EXISTS {$tableName}");

        delete_option('my_plugin_db_version');
    }
}

Repository Pattern

<?php

declare(strict_types=1);

namespace MyPlugin\Repository;

final class BookReviewRepository
{
    private \wpdb $wpdb;
    private string $table;

    public function __construct()
    {
        global $wpdb;
        $this->wpdb = $wpdb;
        $this->table = $wpdb->prefix . 'book_reviews';
    }

    public function create(array $data): int|false
    {
        $result = $this->wpdb->insert(
            $this->table,
            [
                'book_id' => $data['book_id'],
                'user_id' => $data['user_id'],
                'rating'  => $data['rating'],
                'review'  => $data['review'],
                'status'  => $data['status'] ?? 'pending',
            ],
            ['%d', '%d', '%d', '%s', '%s']
        );

        return $result ? $this->wpdb->insert_id : false;
    }

    public function findById(int $id): ?object
    {
        $result = $this->wpdb->get_row(
            $this->wpdb->prepare(
                "SELECT * FROM {$this->table} WHERE id = %d",
                $id
            )
        );

        return $result ?: null;
    }

    public function findByBookId(int $bookId, string $status = 'approved'): array
    {
        return $this->wpdb->get_results(
            $this->wpdb->prepare(
                "SELECT * FROM {$this->table}
                 WHERE book_id = %d AND status = %s
                 ORDER BY created_at DESC",
                $bookId,
                $status
            )
        );
    }

    public function getAverageRating(int $bookId): ?float
    {
        $result = $this->wpdb->get_var(
            $this->wpdb->prepare(
                "SELECT AVG(rating) FROM {$this->table}
                 WHERE book_id = %d AND status = 'approved'",
                $bookId
            )
        );

        return $result !== null ? round((float) $result, 1) : null;
    }

    public function update(int $id, array $data): bool
    {
        $updateData = [];
        $format = [];

        if (isset($data['rating'])) {
            $updateData['rating'] = $data['rating'];
            $format[] = '%d';
        }

        if (isset($data['review'])) {
            $updateData['review'] = $data['review'];
            $format[] = '%s';
        }

        if (isset($data['status'])) {
            $updateData['status'] = $data['status'];
            $format[] = '%s';
        }

        if (empty($updateData)) {
            return false;
        }

        $result = $this->wpdb->update(
            $this->table,
            $updateData,
            ['id' => $id],
            $format,
            ['%d']
        );

        return $result !== false;
    }

    public function delete(int $id): bool
    {
        $result = $this->wpdb->delete(
            $this->table,
            ['id' => $id],
            ['%d']
        );

        return $result !== false;
    }
}

Testing

PHPUnit Setup

<?php
// tests/bootstrap.php

$_tests_dir = getenv('WP_TESTS_DIR') ?: '/tmp/wordpress-tests-lib';

require_once $_tests_dir . '/includes/functions.php';

function _manually_load_plugin(): void
{
    require dirname(__DIR__) . '/my-plugin.php';
}

tests_add_filter('muplugins_loaded', '_manually_load_plugin');

require $_tests_dir . '/includes/bootstrap.php';

Unit Test Example

<?php

declare(strict_types=1);

namespace MyPlugin\Tests;

use MyPlugin\Repository\BookReviewRepository;
use WP_UnitTestCase;

final class BookReviewRepositoryTest extends WP_UnitTestCase
{
    private BookReviewRepository $repository;
    private int $bookId;
    private int $userId;

    public function setUp(): void
    {
        parent::setUp();

        $this->repository = new BookReviewRepository();
        $this->bookId = $this->factory->post->create(['post_type' => 'book']);
        $this->userId = $this->factory->user->create();
    }

    public function test_create_review(): void
    {
        $reviewId = $this->repository->create([
            'book_id' => $this->bookId,
            'user_id' => $this->userId,
            'rating'  => 5,
            'review'  => 'Great book!',
        ]);

        $this->assertIsInt($reviewId);
        $this->assertGreaterThan(0, $reviewId);
    }

    public function test_find_by_id(): void
    {
        $reviewId = $this->repository->create([
            'book_id' => $this->bookId,
            'user_id' => $this->userId,
            'rating'  => 4,
            'review'  => 'Good read.',
        ]);

        $review = $this->repository->findById($reviewId);

        $this->assertNotNull($review);
        $this->assertEquals($this->bookId, $review->book_id);
        $this->assertEquals(4, $review->rating);
    }

    public function test_get_average_rating(): void
    {
        $this->repository->create([
            'book_id' => $this->bookId,
            'user_id' => $this->userId,
            'rating'  => 5,
            'review'  => 'Excellent!',
            'status'  => 'approved',
        ]);

        $this->repository->create([
            'book_id' => $this->bookId,
            'user_id' => $this->factory->user->create(),
            'rating'  => 3,
            'review'  => 'Okay.',
            'status'  => 'approved',
        ]);

        $average = $this->repository->getAverageRating($this->bookId);

        $this->assertEquals(4.0, $average);
    }

    public function test_update_review(): void
    {
        $reviewId = $this->repository->create([
            'book_id' => $this->bookId,
            'user_id' => $this->userId,
            'rating'  => 3,
            'review'  => 'Initial review.',
        ]);

        $updated = $this->repository->update($reviewId, [
            'rating' => 5,
            'review' => 'Updated review.',
        ]);

        $this->assertTrue($updated);

        $review = $this->repository->findById($reviewId);
        $this->assertEquals(5, $review->rating);
        $this->assertEquals('Updated review.', $review->review);
    }

    public function test_delete_review(): void
    {
        $reviewId = $this->repository->create([
            'book_id' => $this->bookId,
            'user_id' => $this->userId,
            'rating'  => 4,
            'review'  => 'To be deleted.',
        ]);

        $deleted = $this->repository->delete($reviewId);

        $this->assertTrue($deleted);
        $this->assertNull($this->repository->findById($reviewId));
    }
}

WP-CLI Commands

Custom Command

<?php

declare(strict_types=1);

namespace MyPlugin\CLI;

use WP_CLI;
use WP_CLI_Command;

if (!defined('WP_CLI') || !WP_CLI) {
    return;
}

final class BookCommand extends WP_CLI_Command
{
    /**
     * List all books.
     *
     * ## OPTIONS
     *
     * [--format=<format>]
     * : Output format (table, json, csv).
     * ---
     * default: table
     * options:
     *   - table
     *   - json
     *   - csv
     * ---
     *
     * [--status=<status>]
     * : Filter by post status.
     *
     * ## EXAMPLES
     *
     *     wp book list
     *     wp book list --format=json
     *     wp book list --status=draft
     *
     * @when after_wp_load
     */
    public function list(array $args, array $assocArgs): void
    {
        $queryArgs = [
            'post_type'      => 'book',
            'posts_per_page' => -1,
            'post_status'    => $assocArgs['status'] ?? 'publish',
        ];

        $books = get_posts($queryArgs);

        if (empty($books)) {
            WP_CLI::warning('No books found.');
            return;
        }

        $items = array_map(fn($book) => [
            'ID'     => $book->ID,
            'Title'  => $book->post_title,
            'Status' => $book->post_status,
            'ISBN'   => get_post_meta($book->ID, '_book_isbn', true),
            'Price'  => get_post_meta($book->ID, '_book_price', true),
        ], $books);

        WP_CLI\Utils\format_items(
            $assocArgs['format'] ?? 'table',
            $items,
            ['ID', 'Title', 'Status', 'ISBN', 'Price']
        );
    }

    /**
     * Create a new book.
     *
     * ## OPTIONS
     *
     * <title>
     * : The book title.
     *
     * [--isbn=<isbn>]
     * : The book ISBN.
     *
     * [--price=<price>]
     * : The book price.
     *
     * [--status=<status>]
     * : Post status (draft, publish).
     * ---
     * default: draft
     * ---
     *
     * ## EXAMPLES
     *
     *     wp book create "My Book Title" --isbn=978-3-16-148410-0 --price=29.99
     *
     * @when after_wp_load
     */
    public function create(array $args, array $assocArgs): void
    {
        $title = $args[0];

        $postId = wp_insert_post([
            'post_type'   => 'book',
            'post_title'  => $title,
            'post_status' => $assocArgs['status'] ?? 'draft',
        ], true);

        if (is_wp_error($postId)) {
            WP_CLI::error($postId->get_error_message());
        }

        if (isset($assocArgs['isbn'])) {
            update_post_meta($postId, '_book_isbn', $assocArgs['isbn']);
        }

        if (isset($assocArgs['price'])) {
            update_post_meta($postId, '_book_price', floatval($assocArgs['price']));
        }

        WP_CLI::success("Book created with ID: {$postId}");
    }

    /**
     * Import books from CSV.
     *
     * ## OPTIONS
     *
     * <file>
     * : Path to CSV file.
     *
     * [--dry-run]
     * : Preview import without creating posts.
     *
     * ## EXAMPLES
     *
     *     wp book import books.csv
     *     wp book import books.csv --dry-run
     *
     * @when after_wp_load
     */
    public function import(array $args, array $assocArgs): void
    {
        $file = $args[0];
        $dryRun = isset($assocArgs['dry-run']);

        if (!file_exists($file)) {
            WP_CLI::error("File not found: {$file}");
        }

        $handle = fopen($file, 'r');
        $headers = fgetcsv($handle);

        $count = 0;

        while (($row = fgetcsv($handle)) !== false) {
            $data = array_combine($headers, $row);

            if ($dryRun) {
                WP_CLI::log("Would import: {$data['title']}");
            } else {
                $postId = wp_insert_post([
                    'post_type'   => 'book',
                    'post_title'  => $data['title'],
                    'post_status' => 'publish',
                ]);

                if (!is_wp_error($postId)) {
                    if (!empty($data['isbn'])) {
                        update_post_meta($postId, '_book_isbn', $data['isbn']);
                    }
                    if (!empty($data['price'])) {
                        update_post_meta($postId, '_book_price', floatval($data['price']));
                    }
                    $count++;
                }
            }
        }

        fclose($handle);

        if ($dryRun) {
            WP_CLI::success('Dry run completed.');
        } else {
            WP_CLI::success("Imported {$count} books.");
        }
    }
}

WP_CLI::add_command('book', BookCommand::class);

Security Best Practices

Input Sanitization and Validation

<?php

// Sanitization functions
$title = sanitize_text_field($_POST['title']);
$email = sanitize_email($_POST['email']);
$url = esc_url_raw($_POST['url']);
$content = wp_kses_post($_POST['content']);
$filename = sanitize_file_name($_POST['filename']);
$key = sanitize_key($_POST['key']);
$textarea = sanitize_textarea_field($_POST['textarea']);
$int = absint($_POST['number']);

// Output escaping
echo esc_html($title);
echo esc_attr($attribute);
echo esc_url($url);
echo esc_js($script);
echo wp_kses_post($content);

// Nonce verification
wp_nonce_field('my_action', 'my_nonce');

if (!wp_verify_nonce($_POST['my_nonce'], 'my_action')) {
    wp_die(__('Security check failed.', 'my-plugin'));
}

// Capability checks
if (!current_user_can('edit_posts')) {
    wp_die(__('You do not have permission.', 'my-plugin'));
}

// Data validation
$rating = intval($_POST['rating']);
if ($rating < 1 || $rating > 5) {
    wp_die(__('Invalid rating.', 'my-plugin'));
}

Performance Tips

<?php

// Use transients for caching
function get_featured_books(): array
{
    $cacheKey = 'my_plugin_featured_books';
    $books = get_transient($cacheKey);

    if ($books === false) {
        $books = get_posts([
            'post_type'      => 'book',
            'posts_per_page' => 10,
            'meta_key'       => '_featured',
            'meta_value'     => '1',
        ]);

        set_transient($cacheKey, $books, HOUR_IN_SECONDS);
    }

    return $books;
}

// Clear cache when data changes
add_action('save_post_book', function (int $postId): void {
    delete_transient('my_plugin_featured_books');
});

// Object caching for repeated queries
function get_book_meta_cached(int $postId): array
{
    $cacheKey = "book_meta_{$postId}";
    $meta = wp_cache_get($cacheKey, 'my_plugin');

    if ($meta === false) {
        $meta = [
            'isbn'  => get_post_meta($postId, '_book_isbn', true),
            'price' => get_post_meta($postId, '_book_price', true),
        ];
        wp_cache_set($cacheKey, $meta, 'my_plugin', 3600);
    }

    return $meta;
}

// Optimize queries
$books = new WP_Query([
    'post_type'              => 'book',
    'posts_per_page'         => 10,
    'no_found_rows'          => true, // Skip count for pagination
    'update_post_meta_cache' => false, // Skip meta cache if not needed
    'update_post_term_cache' => false, // Skip term cache if not needed
    'fields'                 => 'ids', // Only get IDs if that's all you need
]);

Commands Reference

# Development
npm run build          # Build blocks/assets
npm run start          # Watch mode
composer install       # Install PHP dependencies

# Testing
./vendor/bin/phpunit              # Run tests
./vendor/bin/phpunit --coverage-html coverage

# Code Quality
./vendor/bin/phpcs               # PHP CodeSniffer
./vendor/bin/phpcbf              # Auto-fix coding standards
./vendor/bin/phpstan analyse     # Static analysis

# WP-CLI
wp plugin activate my-plugin
wp plugin deactivate my-plugin
wp book list                     # Custom command
wp book create "New Book"

References