Refactor high-complexity React components in Dify frontend. Use when `pnpm analyze-component...
npx skills add xonack/wp-php-architecture-claude-skill --skill "wp-php-architecture"
Install specific skill from multi-skill repository
# Description
WordPress PHP architecture patterns — repository, service layer, DDD, SOLID, plugin/theme structure, CPT design, and anti-patterns. The definitive reference for professional WordPress PHP development.
# SKILL.md
name: wp-php-architecture
description: WordPress PHP architecture patterns — repository, service layer, DDD, SOLID, plugin/theme structure, CPT design, and anti-patterns. The definitive reference for professional WordPress PHP development.
tools:
- Read
- Write
- Edit
- Grep
- Glob
- Bash
WordPress PHP Architecture Patterns
This skill encodes battle-tested architecture patterns for WordPress PHP development. Every pattern is adapted to WordPress realities: global state, hook-driven execution, no native DI container, and the functions.php bootstrap model. Use these patterns to write WordPress code that is testable, maintainable, and scalable.
1. Repository Pattern for WordPress
Wrap all data access behind repository classes. Never scatter $wpdb calls or raw WP_Query usage across business logic.
Base Repository Interface
interface PostRepositoryInterface {
public function find_by_id( int $id ): ?object;
public function find_all( array $criteria = [] ): array;
public function save( object $entity ): int;
public function delete( int $id ): bool;
}
WP_Query Repository Implementation
class ProductRepository implements PostRepositoryInterface {
private string $post_type = 'product';
public function find_by_id( int $id ): ?Product {
$post = get_post( $id );
if ( ! $post || $post->post_type !== $this->post_type ) {
return null;
}
return $this->hydrate( $post );
}
public function find_all( array $criteria = [] ): array {
$defaults = [
'post_type' => $this->post_type,
'posts_per_page' => 20,
'post_status' => 'publish',
];
$query = new WP_Query( array_merge( $defaults, $criteria ) );
return array_map( [ $this, 'hydrate' ], $query->posts );
}
public function save( object $entity ): int {
$args = [
'post_type' => $this->post_type,
'post_title' => $entity->get_name(),
'post_content' => $entity->get_description(),
'post_status' => 'publish',
];
if ( $entity->get_id() ) {
$args['ID'] = $entity->get_id();
wp_update_post( $args );
$id = $entity->get_id();
} else {
$id = wp_insert_post( $args );
}
update_post_meta( $id, '_product_price', $entity->get_price()->amount() );
update_post_meta( $id, '_product_sku', $entity->get_sku() );
return $id;
}
public function delete( int $id ): bool {
return (bool) wp_delete_post( $id, true );
}
private function hydrate( WP_Post $post ): Product {
return new Product(
id: $post->ID,
name: $post->post_title,
description: $post->post_content,
price: new Money( (float) get_post_meta( $post->ID, '_product_price', true ) ),
sku: (string) get_post_meta( $post->ID, '_product_sku', true ),
);
}
}
Direct $wpdb Repository (for custom tables)
class OrderRepository {
private wpdb $db;
private string $table;
public function __construct() {
global $wpdb;
$this->db = $wpdb;
$this->table = $wpdb->prefix . 'custom_orders';
}
public function find_by_id( int $id ): ?Order {
$row = $this->db->get_row(
$this->db->prepare( "SELECT * FROM {$this->table} WHERE id = %d", $id )
);
return $row ? Order::from_row( $row ) : null;
}
public function find_by_customer( int $customer_id, int $limit = 50 ): array {
$rows = $this->db->get_results(
$this->db->prepare(
"SELECT * FROM {$this->table} WHERE customer_id = %d ORDER BY created_at DESC LIMIT %d",
$customer_id,
$limit
)
);
return array_map( [ Order::class, 'from_row' ], $rows );
}
}
Key rule: Business logic calls $repository->find_by_id(), never get_post() directly. This makes the code testable (swap the repository) and decouples domain logic from WordPress data layer.
2. Service Layer Pattern
Services contain business logic. They depend on repositories (not on WordPress globals) and are registered via hooks at bootstrap time.
Service Class
class OrderService {
private OrderRepository $orders;
private ProductRepository $products;
private NotificationService $notifications;
public function __construct(
OrderRepository $orders,
ProductRepository $products,
NotificationService $notifications
) {
$this->orders = $orders;
$this->products = $products;
$this->notifications = $notifications;
}
public function place_order( int $customer_id, array $items ): Order {
$line_items = [];
foreach ( $items as $item ) {
$product = $this->products->find_by_id( $item['product_id'] );
if ( ! $product ) {
throw new InvalidProductException( $item['product_id'] );
}
$line_items[] = new LineItem( $product, $item['quantity'] );
}
$order = Order::create( $customer_id, $line_items );
$this->orders->save( $order );
// Domain event via WordPress hook system
do_action( 'myshop_order_placed', $order );
$this->notifications->send_order_confirmation( $order );
return $order;
}
}
Service Registration (Simple Factory at Bootstrap)
WordPress has no DI container. Use a static factory or a lightweight service locator in the main plugin file.
class MyShopPlugin {
private static ?self $instance = null;
private array $services = [];
public static function instance(): self {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
private function __construct() {
$this->register_services();
$this->register_hooks();
}
private function register_services(): void {
$products = new ProductRepository();
$orders = new OrderRepository();
$notifications = new NotificationService();
$this->services['order_service'] = new OrderService( $orders, $products, $notifications );
$this->services['product_repo'] = $products;
}
public function get( string $key ): object {
if ( ! isset( $this->services[ $key ] ) ) {
throw new RuntimeException( "Service '{$key}' not registered." );
}
return $this->services[ $key ];
}
private function register_hooks(): void {
add_action( 'rest_api_init', [ $this, 'register_rest_routes' ] );
add_action( 'init', [ $this, 'register_post_types' ] );
}
}
// Bootstrap in main plugin file:
add_action( 'plugins_loaded', [ MyShopPlugin::class, 'instance' ] );
Key rule: Services never call add_action or add_filter themselves. The plugin bootstrap wires hooks to service methods. Services stay framework-agnostic and testable.
3. Domain-Driven Design Adapted for WordPress
Value Objects
Value objects are immutable, compared by value, and encapsulate validation.
final class Money {
private float $amount;
private string $currency;
public function __construct( float $amount, string $currency = 'USD' ) {
if ( $amount < 0 ) {
throw new InvalidArgumentException( 'Amount cannot be negative.' );
}
$this->amount = round( $amount, 2 );
$this->currency = strtoupper( $currency );
}
public function amount(): float { return $this->amount; }
public function currency(): string { return $this->currency; }
public function add( Money $other ): self {
if ( $this->currency !== $other->currency ) {
throw new CurrencyMismatchException();
}
return new self( $this->amount + $other->amount, $this->currency );
}
public function equals( Money $other ): bool {
return $this->amount === $other->amount && $this->currency === $other->currency;
}
}
final class EmailAddress {
private string $value;
public function __construct( string $email ) {
if ( ! is_email( $email ) ) { // Uses WP's is_email() validator
throw new InvalidArgumentException( "Invalid email: {$email}" );
}
$this->value = sanitize_email( $email );
}
public function value(): string { return $this->value; }
}
Entities as CPTs
A Custom Post Type IS a WordPress entity. Map domain entities to CPTs, but keep the domain model clean by using the repository to hydrate and persist.
class Product {
private ?int $id;
private string $name;
private string $description;
private Money $price;
private string $sku;
public function __construct( ?int $id, string $name, string $description, Money $price, string $sku ) {
$this->id = $id;
$this->name = $name;
$this->description = $description;
$this->price = $price;
$this->sku = $sku;
}
public function get_id(): ?int { return $this->id; }
public function get_name(): string { return $this->name; }
public function get_description(): string { return $this->description; }
public function get_price(): Money { return $this->price; }
public function get_sku(): string { return $this->sku; }
public function apply_discount( float $percent ): void {
$discounted = $this->price->amount() * ( 1 - $percent / 100 );
$this->price = new Money( $discounted );
}
}
Domain Events via WordPress Hooks
do_action IS the domain event dispatcher. add_action IS the event subscriber.
// Dispatching a domain event (in service layer):
do_action( 'myshop_order_placed', $order );
do_action( 'myshop_product_price_changed', $product, $old_price, $new_price );
// Subscribing to domain events (at bootstrap):
add_action( 'myshop_order_placed', [ $inventory_service, 'decrement_stock' ] );
add_action( 'myshop_order_placed', [ $analytics_service, 'track_purchase' ] );
add_action( 'myshop_product_price_changed', [ $cache_service, 'invalidate_product_cache' ] );
Bounded Contexts as Plugins
Each bounded context (catalog, ordering, shipping, analytics) maps to a separate plugin. Cross-context communication uses do_action / apply_filters only. Never import classes across plugin boundaries. Define shared interfaces in a common package if needed.
4. SOLID Principles in WordPress
Single Responsibility Principle (SRP)
One hook callback does one thing. If a callback grows beyond 15 lines, extract a service method.
// BAD: callback does validation, processing, notification, and logging
add_action( 'save_post', 'do_everything_on_save' );
// GOOD: one callback, one responsibility
add_action( 'save_post_product', [ $product_service, 'sync_inventory_on_save' ], 10, 2 );
add_action( 'save_post_product', [ $cache_service, 'invalidate_on_save' ], 20, 2 );
add_action( 'save_post_product', [ $audit_service, 'log_product_change' ], 30, 2 );
Open/Closed Principle (OCP)
Use apply_filters to make behavior extensible without modifying source code.
class PricingEngine {
public function calculate( Product $product, int $quantity ): Money {
$unit_price = $product->get_price();
$total = new Money( $unit_price->amount() * $quantity );
// Extensible via filters - other plugins can modify pricing
$total = apply_filters( 'myshop_calculated_price', $total, $product, $quantity );
return $total;
}
}
// A separate plugin adds a bulk discount without touching PricingEngine:
add_filter( 'myshop_calculated_price', function( Money $total, Product $product, int $qty ): Money {
if ( $qty >= 10 ) {
return new Money( $total->amount() * 0.9 );
}
return $total;
}, 10, 3 );
Liskov Substitution Principle (LSP)
Abstract base classes for CPT handlers ensure all subtypes are interchangeable.
abstract class PostTypeHandler {
abstract protected function get_post_type(): string;
abstract protected function get_labels(): array;
abstract protected function get_args(): array;
public function register(): void {
register_post_type( $this->get_post_type(), array_merge(
$this->get_args(),
[ 'labels' => $this->get_labels() ]
) );
}
}
Interface Segregation Principle (ISP)
Small, targeted interfaces rather than one monolithic plugin API.
interface Renderable {
public function render(): string;
}
interface Cacheable {
public function cache_key(): string;
public function cache_ttl(): int;
}
interface RestExposable {
public function to_rest_response(): array;
public function get_rest_schema(): array;
}
// A class implements only what it needs:
class ProductCard implements Renderable, Cacheable {
public function render(): string { /* ... */ }
public function cache_key(): string { return "product_card_{$this->id}"; }
public function cache_ttl(): int { return HOUR_IN_SECONDS; }
}
Dependency Inversion Principle (DIP)
Depend on interfaces, not WordPress globals.
// BAD: tight coupling to WordPress global
class ReportGenerator {
public function get_data(): array {
global $wpdb;
return $wpdb->get_results( "SELECT ..." );
}
}
// GOOD: depend on an abstraction
class ReportGenerator {
private ReportDataSourceInterface $source;
public function __construct( ReportDataSourceInterface $source ) {
$this->source = $source;
}
public function get_data(): array {
return $this->source->fetch_report_data();
}
}
5. Plugin Architecture Patterns
Main Plugin File Structure
<?php
/**
* Plugin Name: My Shop
*/
defined( 'ABSPATH' ) || exit;
define( 'MYSHOP_VERSION', '1.0.0' );
define( 'MYSHOP_PATH', plugin_dir_path( __FILE__ ) );
define( 'MYSHOP_URL', plugin_dir_url( __FILE__ ) );
// Composer autoloader
require_once MYSHOP_PATH . 'vendor/autoload.php';
// Bootstrap
add_action( 'plugins_loaded', [ MyShop\Plugin::class, 'instance' ] );
// Activation/deactivation
register_activation_hook( __FILE__, [ MyShop\Installer::class, 'activate' ] );
register_deactivation_hook( __FILE__, [ MyShop\Installer::class, 'deactivate' ] );
Directory Layout
my-shop/
my-shop.php # Bootstrap only
composer.json # Autoloading, dependencies
src/
Plugin.php # Singleton service locator
Installer.php # Activation, DB table creation
Domain/
Product.php # Entity
Order.php # Aggregate root
Money.php # Value object
Repository/
ProductRepository.php
OrderRepository.php
Service/
OrderService.php
PricingEngine.php
Admin/
ProductAdminPage.php
OrderListTable.php
Rest/
ProductController.php
templates/
admin/
product-edit.php
frontend/
product-card.php
tests/
Unit/
Integration/
Strategy Pattern for Rendering
interface RenderStrategy {
public function render( array $data ): string;
}
class GridRenderStrategy implements RenderStrategy {
public function render( array $data ): string {
ob_start();
include MYSHOP_PATH . 'templates/frontend/product-grid.php';
return ob_get_clean();
}
}
class ListRenderStrategy implements RenderStrategy {
public function render( array $data ): string {
ob_start();
include MYSHOP_PATH . 'templates/frontend/product-list.php';
return ob_get_clean();
}
}
class ProductDisplay {
private RenderStrategy $strategy;
public function __construct( RenderStrategy $strategy ) {
$this->strategy = $strategy;
}
public function output( array $products ): string {
return $this->strategy->render( [ 'products' => $products ] );
}
}
Template Method for Admin Pages
abstract class AdminPage {
abstract protected function get_page_title(): string;
abstract protected function get_menu_slug(): string;
abstract protected function get_capability(): string;
abstract protected function render_content(): void;
public function register(): void {
add_menu_page(
$this->get_page_title(),
$this->get_page_title(),
$this->get_capability(),
$this->get_menu_slug(),
[ $this, 'render' ]
);
}
public function render(): void {
if ( ! current_user_can( $this->get_capability() ) ) {
wp_die( 'Unauthorized.' );
}
echo '<div class="wrap">';
echo '<h1>' . esc_html( $this->get_page_title() ) . '</h1>';
$this->render_content();
echo '</div>';
}
}
6. Theme Architecture
Child Theme Directory Structure
oshin_child/
style.css # Theme header + minimal styles
functions.php # Bootstrap ONLY - no logic here
inc/
setup.php # Theme supports, menus, sidebars
enqueue.php # Script and style registration
hooks.php # Custom action/filter callbacks
template-functions.php # Helper functions for templates
customizer.php # Customizer settings
template-parts/
content/
content-product.php
content-page.php
components/
hero-banner.php
cta-block.php
assets/
css/
js/
images/
functions.php as Pure Bootstrap
<?php
// functions.php - BOOTSTRAP ONLY. No logic. No HTML. No queries.
defined( 'ABSPATH' ) || exit;
define( 'CHILD_THEME_VERSION', '1.0.0' );
define( 'CHILD_THEME_DIR', get_stylesheet_directory() );
define( 'CHILD_THEME_URI', get_stylesheet_directory_uri() );
// Load organized includes
require_once CHILD_THEME_DIR . '/inc/setup.php';
require_once CHILD_THEME_DIR . '/inc/enqueue.php';
require_once CHILD_THEME_DIR . '/inc/hooks.php';
require_once CHILD_THEME_DIR . '/inc/template-functions.php';
Template Hierarchy Mastery
Always use the most specific template file. WordPress resolves templates in order of specificity. Use get_template_part() for reusable sections.
// In archive-product.php
get_header();
if ( have_posts() ) :
while ( have_posts() ) : the_post();
// Loads template-parts/content/content-product.php
get_template_part( 'template-parts/content/content', get_post_type() );
endwhile;
the_posts_pagination();
endif;
get_footer();
7. Custom Post Type Architecture
Registration Pattern
class ProductPostType extends PostTypeHandler {
protected function get_post_type(): string {
return 'product';
}
protected function get_labels(): array {
return [
'name' => 'Products',
'singular_name' => 'Product',
'add_new_item' => 'Add New Product',
'edit_item' => 'Edit Product',
];
}
protected function get_args(): array {
return [
'public' => true,
'has_archive' => true,
'rewrite' => [ 'slug' => 'products' ],
'supports' => [ 'title', 'editor', 'thumbnail', 'excerpt' ],
'show_in_rest' => true,
'menu_icon' => 'dashicons-cart',
];
}
}
Meta Box Architecture
class ProductMetaBox {
private string $post_type = 'product';
private string $nonce_action = 'product_meta_save';
private string $nonce_name = '_product_meta_nonce';
public function register(): void {
add_action( 'add_meta_boxes', [ $this, 'add_meta_boxes' ] );
add_action( "save_post_{$this->post_type}", [ $this, 'save' ], 10, 2 );
}
public function add_meta_boxes(): void {
add_meta_box( 'product-details', 'Product Details', [ $this, 'render' ], $this->post_type );
}
public function render( WP_Post $post ): void {
wp_nonce_field( $this->nonce_action, $this->nonce_name );
$price = get_post_meta( $post->ID, '_product_price', true );
$sku = get_post_meta( $post->ID, '_product_sku', true );
include MYSHOP_PATH . 'templates/admin/product-meta-box.php';
}
public function save( int $post_id, WP_Post $post ): void {
if ( ! isset( $_POST[ $this->nonce_name ] ) ) return;
if ( ! wp_verify_nonce( $_POST[ $this->nonce_name ], $this->nonce_action ) ) return;
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) return;
if ( ! current_user_can( 'edit_post', $post_id ) ) return;
if ( isset( $_POST['product_price'] ) ) {
update_post_meta( $post_id, '_product_price', sanitize_text_field( $_POST['product_price'] ) );
}
if ( isset( $_POST['product_sku'] ) ) {
update_post_meta( $post_id, '_product_sku', sanitize_text_field( $_POST['product_sku'] ) );
}
}
}
Query Optimization for CPTs
// Pre-fetch all needed meta in one query instead of N+1
function myshop_prefetch_product_meta( array $post_ids ): void {
if ( empty( $post_ids ) ) return;
update_meta_cache( 'post', $post_ids );
}
// Use pre_get_posts to modify the main query, not a new WP_Query
add_action( 'pre_get_posts', function( WP_Query $query ): void {
if ( $query->is_main_query() && $query->is_post_type_archive( 'product' ) ) {
$query->set( 'posts_per_page', 24 );
$query->set( 'orderby', 'meta_value_num' );
$query->set( 'meta_key', '_product_price' );
}
} );
8. Anti-Patterns to Avoid
God functions.php
Never put business logic, HTML templates, database queries, or AJAX handlers directly in functions.php. It is a bootstrap file, not a dumping ground.
Direct $wpdb in Templates
// NEVER do this in a template file:
global $wpdb;
$results = $wpdb->get_results( "SELECT * FROM ..." );
// ALWAYS use a repository or helper, called from the template:
$products = MyShopPlugin::instance()->get( 'product_repo' )->find_all();
Mixing Concerns in Hook Callbacks
// BAD: one callback validates, saves, sends email, logs, and redirects
add_action( 'save_post', function( $id ) {
// 80 lines of mixed concerns
});
// GOOD: discrete, testable operations
add_action( 'save_post_product', [ $validator, 'validate' ], 5 );
add_action( 'save_post_product', [ $repo, 'sync' ], 10 );
add_action( 'save_post_product', [ $notifier, 'notify' ], 20 );
Ignoring Transients for Expensive Queries
// ALWAYS cache expensive queries:
function myshop_get_featured_products(): array {
$cached = get_transient( 'myshop_featured_products' );
if ( false !== $cached ) {
return $cached;
}
$repo = MyShopPlugin::instance()->get( 'product_repo' );
$products = $repo->find_all( [ 'meta_key' => '_featured', 'meta_value' => '1' ] );
set_transient( 'myshop_featured_products', $products, HOUR_IN_SECONDS );
return $products;
}
// Invalidate on change:
add_action( 'save_post_product', function(): void {
delete_transient( 'myshop_featured_products' );
} );
Hardcoded Queries and Magic Numbers
// BAD:
$wpdb->get_results( "SELECT * FROM wp_posts WHERE post_type = 'product' LIMIT 10" );
// GOOD: use $wpdb->prefix and named constants
$wpdb->get_results( $wpdb->prepare(
"SELECT * FROM {$wpdb->posts} WHERE post_type = %s LIMIT %d",
'product',
self::DEFAULT_LIMIT
) );
Summary of Rules
- Repositories wrap all data access. Templates and services never touch
$wpdborWP_Querydirectly. - Services contain business logic, receive dependencies via constructor, and never call
add_actionthemselves. - Value Objects enforce domain rules at construction time. Use them for prices, emails, slugs, any constrained type.
- Hooks are domain events. Use
do_actionto dispatch,add_actionto subscribe. Wire them at bootstrap only. - Plugins are bounded contexts. Cross-plugin communication happens only through hooks and shared interfaces.
- functions.php is a bootloader. It requires files. It defines constants. It does nothing else.
- Cache everything expensive. Transients for queries, object cache for session data,
update_meta_cacheto prevent N+1. - Prepare all SQL. Every
$wpdbcall uses$wpdb->prepare(). No exceptions. No string concatenation in queries.
# Supported AI Coding Agents
This skill is compatible with the SKILL.md standard and works with all major AI coding agents:
Learn more about the SKILL.md standard and how to use these skills with your preferred AI coding agent.