Scaffold Walkthrough

Build a complete WordPress plugin from scratch using the WPZylos scaffold.

What We'll Build

A "Contact Manager" plugin with:

  • Custom database table for contacts
  • Admin dashboard with contact list
  • Add/edit contact forms
  • AJAX-powered delete
  • Proper security (nonce, capability, sanitization)

Prerequisites

  • PHP 8.1+
  • WordPress 6.0+ installed locally
  • Composer 2.0+ installed
  • WP-CLI installed (recommended)

Step 1: Create Project

composer create-project MyCompanystudio/wpzylos-scaffold contact-manager
cd contact-manager

Step 2: Initialize Plugin

Run the scaffold CLI to configure your plugin identity:

# Windows (PowerShell)
.\scaffold.ps1 init

# Linux/Mac
./scaffold.sh init

The CLI will ask for:

PromptEnter ThisDescription
Plugin NameContact ManagerDisplay name
Plugin Slugcontact-managerLowercase, hyphenated
NamespaceContactManagerPascalCase PHP namespace
Scoper Prefixcontact_managerFor PHP-Scoper isolation
Database Prefixcm_For DB tables and options
Author NameYour NamePlugin author

After init, your plugin is renamed and configured. The scaffold saves all settings to .plugin-config.json.

Step 3: Understand the Generated Structure

your-plugin/
+-- app/                        # Application code (PSR-4)
|   +-- Core/
|   |   +-- PluginContext.php    # Plugin identity
|   +-- Http/
|   |   +-- Controllers/        # Request handlers
|   +-- Providers/              # Service providers
|   +-- Services/               # Business logic
|   +-- Support/
|   |   +-- helpers.php         # Global helpers
|   +-- Lifecycle/
|       +-- Activator.php       # Activation logic
|       +-- Deactivator.php     # Deactivation logic
|       +-- Uninstaller.php     # Uninstall cleanup
+-- bootstrap/
|   +-- app.php                 # Bootstrap & providers
+-- config/
|   +-- app.php                 # Configuration
+-- database/
|   +-- migrations/             # Schema migrations
+-- resources/
|   +-- lang/                   # Translations
|   +-- views/                  # Templates
+-- routes/
|   +-- web.php                 # Route definitions
+-- tests/                      # PHPUnit tests
+-- your-plugin.php             # Main entry point
+-- uninstall.php               # WordPress uninstall
+-- scaffold.ps1                # CLI (Windows)
+-- scaffold.sh                 # CLI (Linux/Mac)
+-- scoper.inc.php              # PHP-Scoper config
+-- composer.json

Step 4: Understand the Plugin File

The main plugin file (contact-manager.php) follows this pattern:

<?php
/**
 * Plugin Name: Contact Manager
 * Description: A contact management plugin built with WPZylos.
 * Version: 1.0.0
 * Requires PHP: 8.1
 * Text Domain: contact-manager
 * Domain Path: /resources/lang
 */

declare(strict_types=1);

defined('ABSPATH') || exit;

if (file_exists(__DIR__ . '/vendor/autoload.php')) {
    require_once __DIR__ . '/vendor/autoload.php';
}

use ContactManager\Core\PluginContext;
use ContactManager\Lifecycle\Activator;
use ContactManager\Lifecycle\Deactivator;

// Create plugin context - single source of truth for plugin identity
$context = PluginContext::create([
    'file'       => __FILE__,
    'slug'       => 'contact-manager',
    'prefix'     => 'cm_',
    'textDomain' => 'contact-manager',
    'version'    => '1.0.0',
    'namespace'  => 'ContactManager',
]);

// Activation/Deactivation hooks
register_activation_hook(__FILE__, static function () use ($context) {
    Activator::activate($context);
});

register_deactivation_hook(__FILE__, static function () use ($context) {
    Deactivator::deactivate($context);
});

// Bootstrap the application on plugins_loaded
add_action('plugins_loaded', static function () use ($context) {
    $bootstrap = require __DIR__ . '/bootstrap/app.php';
    if (is_callable($bootstrap)) {
        $bootstrap($context);
    }
});

Key points:

  • PluginContext::create([...]) provides all plugin identity in one place
  • Activation uses dedicated Activator class, not inline code
  • Bootstrap returns a callable that receives context and boots internally
  • Application boots during plugins_loaded, not earlier

Step 5: Understand the Bootstrap

The bootstrap/app.php registers framework providers in dependency order:

<?php
// bootstrap/app.php

declare(strict_types=1);

use ContactManager\Core\PluginContext;
use WPZylos\Framework\Core\Application;
use WPZylos\Framework\Config\ConfigServiceProvider;
use WPZylos\Framework\Hooks\HookServiceProvider;
use WPZylos\Framework\Database\DatabaseServiceProvider;
// ... more providers

return static function (PluginContext $context): Application {
    $app = new Application($context);

    // Phase 1: Foundation (no dependencies)
    $app->register(new ConfigServiceProvider());       // Config + .env
    $app->register(new LoggerServiceProvider());       // PSR-3 Logger
    $app->register(new I18nServiceProvider());         // i18n + translator
    $app->register(new HookServiceProvider());         // Hook manager
    $app->register(new EventServiceProvider());        // PSR-14 Events

    // Phase 2: Security (depends on i18n for messages)
    $app->register(new SecurityServiceProvider());     // Nonce, Gate, Sanitizer

    // Phase 3: HTTP (depends on security)
    $app->register(new HttpServiceProvider());         // Request, Response

    // Phase 4: Validation + Views (depend on i18n)
    $app->register(new ValidationServiceProvider());   // Validator, FormRequest
    $app->register(new ViewsServiceProvider());        // ViewFactory

    // Phase 5: Database + Model + Migrations
    $app->register(new DatabaseServiceProvider());     // Connection, QueryBuilder
    $app->register(new ModelServiceProvider());        // ORM Model layer
    $app->register(new MigrationsServiceProvider());   // Migrator

    // Phase 6: Routing (depends on HTTP, container)
    $app->register(new RoutingServiceProvider());      // Router, Dispatcher

    // Phase 7: Queue, Mail, Notification, Scheduler, Assets
    $app->register(new QueueServiceProvider());
    $app->register(new MailServiceProvider());
    $app->register(new NotificationServiceProvider());
    $app->register(new SchedulerServiceProvider());
    $app->register(new AssetsServiceProvider());

    // Phase 8: CLI (only when WP_CLI)
    if (defined('WP_CLI') && WP_CLI) {
        $app->register(new WpCliServiceProvider());
    }

    // Boot the application
    $app->boot();

    return $app;
};

Provider registration order matters - dependencies must be registered before dependents.

Step 6: Create Service Provider

Create app/Providers/AppServiceProvider.php:

<?php

namespace ContactManager\Providers;

use WPZylos\Framework\Core\ServiceProvider;
use WPZylos\Framework\Core\Contracts\ApplicationInterface;
use WPZylos\Framework\Hooks\HookManager;
use ContactManager\Services\ContactService;
use ContactManager\Http\Controllers\ContactController;

class AppServiceProvider extends ServiceProvider
{
    public function register(ApplicationInterface $app): void
    {
        parent::register($app);

        // Bind application services
        $app->singleton(ContactService::class);
    }

    public function boot(ApplicationInterface $app): void
    {
        if (!is_admin()) {
            return;
        }

        $hooks = $app->make(HookManager::class);

        $hooks->wpAction('admin_menu', function () use ($app) {
            $this->registerAdminMenu($app);
        });

        $hooks->wpAction('admin_enqueue_scripts', function (string $hook) use ($app) {
            $this->enqueueAdminAssets($hook, $app);
        });
    }

    private function registerAdminMenu(ApplicationInterface $app): void
    {
        $context = $app->context();
        $controller = $app->make(ContactController::class);

        add_menu_page(
            __('Contacts', $context->textDomain()),
            __('Contacts', $context->textDomain()),
            'manage_options',
            $context->slug(),
            [$controller, 'index'],
            'dashicons-groups',
            30
        );

        add_submenu_page(
            $context->slug(),
            __('Add New', $context->textDomain()),
            __('Add New', $context->textDomain()),
            'manage_options',
            $context->slug() . '-add',
            [$controller, 'create']
        );
    }

    private function enqueueAdminAssets(string $hook, ApplicationInterface $app): void
    {
        if (!str_contains($hook, $app->context()->slug())) {
            return;
        }

        wp_enqueue_style(
            $app->context()->prefix() . 'admin',
            $app->context()->url('resources/assets/admin.css'),
            [],
            $app->context()->version()
        );
    }
}

Then register it in bootstrap/app.php:

// After Phase 6: Routing
$app->register(new \ContactManager\Providers\AppServiceProvider());

Step 7: Create Contact Service

Create app/Services/ContactService.php:

<?php

namespace ContactManager\Services;

use WPZylos\Framework\Database\Connection;
use WPZylos\Framework\Core\Contracts\ContextInterface;

class ContactService
{
    private string $table;

    public function __construct(
        private Connection $db,
        private ContextInterface $context
    ) {
        global $wpdb;
        $this->table = $wpdb->prefix . $this->context->prefix() . 'contacts';
    }

    public function all(): array
    {
        global $wpdb;
        return $wpdb->get_results(
            "SELECT * FROM {$this->table} ORDER BY created_at DESC"
        );
    }

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

    public function create(array $data): int
    {
        global $wpdb;
        $wpdb->insert($this->table, $data);
        return (int) $wpdb->insert_id;
    }

    public function update(int $id, array $data): int
    {
        global $wpdb;
        return (int) $wpdb->update($this->table, $data, ['id' => $id]);
    }

    public function delete(int $id): int
    {
        global $wpdb;
        return (int) $wpdb->delete($this->table, ['id' => $id]);
    }
}

Step 8: Create Controller

Create app/Http/Controllers/ContactController.php:

<?php

namespace ContactManager\Http\Controllers;

use WPZylos\Framework\Views\ViewFactory;
use WPZylos\Framework\Security\Nonce;
use WPZylos\Framework\Security\Sanitizer;
use WPZylos\Framework\Validation\Validator;
use WPZylos\Framework\Core\Contracts\ContextInterface;
use ContactManager\Services\ContactService;

class ContactController
{
    public function __construct(
        private ViewFactory $view,
        private ContactService $contacts,
        private Nonce $nonce,
        private Sanitizer $sanitizer,
        private ContextInterface $context
    ) {
    }

    public function index(): void
    {
        $contacts = $this->contacts->all();

        echo $this->view->render('admin/index', [
            'title'       => __('Contacts', $this->context->textDomain()),
            'contacts'    => $contacts,
            'deleteNonce' => $this->nonce->create('delete_contact'),
        ]);
    }

    public function create(): void
    {
        echo $this->view->render('admin/create', [
            'title' => __('Add Contact', $this->context->textDomain()),
            'nonce' => $this->nonce->create('create_contact'),
        ]);
    }

    public function store(): void
    {
        if (!$this->nonce->verify($_POST['_nonce'] ?? '', 'create_contact')) {
            wp_die(__('Invalid nonce', $this->context->textDomain()));
        }

        if (!current_user_can('manage_options')) {
            wp_die(__('Unauthorized', $this->context->textDomain()));
        }

        $validator = new Validator();
        $result = $validator->validate($_POST, [
            'name'  => 'required|string|max:255',
            'email' => 'required|email',
            'phone' => 'nullable|string|max:50',
            'notes' => 'nullable|string',
        ]);

        if ($result->fails()) {
            set_transient(
                $this->context->prefix() . 'form_errors',
                $result->errors(),
                30
            );
            wp_redirect(admin_url('admin.php?page=' . $this->context->slug() . '-add&error=1'));
            exit;
        }

        $this->contacts->create([
            'name'  => $this->sanitizer->text($_POST['name']),
            'email' => $this->sanitizer->email($_POST['email']),
            'phone' => $this->sanitizer->text($_POST['phone'] ?? ''),
            'notes' => $this->sanitizer->textarea($_POST['notes'] ?? ''),
        ]);

        wp_redirect(admin_url('admin.php?page=' . $this->context->slug() . '&saved=1'));
        exit;
    }
}

Step 9: Setup Activation (Database Table)

The Activator class already exists at app/Lifecycle/Activator.php. Add the contacts table creation:

<?php

namespace ContactManager\Lifecycle;

use ContactManager\Core\PluginContext;

class Activator
{
    public static function activate(PluginContext $context): void
    {
        // Check requirements
        if (!self::checkRequirements()) {
            deactivate_plugins(plugin_basename($context->file()));
            wp_die(
                esc_html__('Requires PHP 8.1+ and WordPress 6.0+', $context->textDomain()),
                esc_html__('Plugin Activation Error', $context->textDomain()),
                ['back_link' => true]
            );
        }

        // Create database table
        self::createTables($context);

        // Set defaults
        self::setDefaults($context);

        // Flush rewrite rules
        flush_rewrite_rules();
    }

    private static function checkRequirements(): bool
    {
        return PHP_VERSION_ID >= 80100
            && version_compare(get_bloginfo('version'), '6.0', '>=');
    }

    private static function createTables(PluginContext $context): void
    {
        require_once ABSPATH . 'wp-admin/includes/upgrade.php';

        global $wpdb;
        $table   = $wpdb->prefix . $context->prefix() . 'contacts';
        $charset = $wpdb->get_charset_collate();

        $sql = "CREATE TABLE $table (
            id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
            name varchar(255) NOT NULL,
            email varchar(255) NOT NULL,
            phone varchar(50) DEFAULT '',
            notes text,
            created_at datetime DEFAULT CURRENT_TIMESTAMP,
            updated_at datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
            PRIMARY KEY (id),
            KEY email (email)
        ) $charset;";

        dbDelta($sql);
    }

    private static function setDefaults(PluginContext $context): void
    {
        update_option($context->prefix() . 'version', $context->version());
    }
}

Step 10: Create Views

Create resources/views/admin/index.php:

<?php
/** @var array $contacts */
/** @var string $title */
/** @var string $deleteNonce */
?>
<div class="wrap">
    <h1 class="wp-heading-inline"><?php echo esc_html($title); ?></h1>
    <a href="<?php echo esc_url(admin_url('admin.php?page=' . $context->slug() . '-add')); ?>" class="page-title-action">
        <?php esc_html_e('Add New', $context->textDomain()); ?>
    </a>

    <?php if (isset($_GET['saved'])): ?>
        <div class="notice notice-success"><p><?php esc_html_e('Contact saved!', $context->textDomain()); ?></p></div>
    <?php endif; ?>

    <table class="wp-list-table widefat striped">
        <thead>
            <tr>
                <th><?php esc_html_e('Name', $context->textDomain()); ?></th>
                <th><?php esc_html_e('Email', $context->textDomain()); ?></th>
                <th><?php esc_html_e('Phone', $context->textDomain()); ?></th>
                <th><?php esc_html_e('Actions', $context->textDomain()); ?></th>
            </tr>
        </thead>
        <tbody>
            <?php foreach ($contacts as $contact): ?>
            <tr>
                <td><?php echo esc_html($contact->name); ?></td>
                <td><?php echo esc_html($contact->email); ?></td>
                <td><?php echo esc_html($contact->phone); ?></td>
                <td>
                    <a href="<?php echo esc_url(admin_url('admin.php?page=' . $context->slug() . '-edit&id=' . $contact->id)); ?>">
                        <?php esc_html_e('Edit', $context->textDomain()); ?>
                    </a>
                </td>
            </tr>
            <?php endforeach; ?>
        </tbody>
    </table>
</div>

Step 11: Update Composer Autoload

Your composer.json should have:

{
  "autoload": {
    "psr-4": {
      "ContactManager\\": "app/"
    }
  }
}

Run:

composer dump-autoload

Step 12: Activate and Test

wp plugin activate contact-manager

Visit WordPress Admin -> Contacts to see your plugin.

Step 13: Build for Production

When ready to distribute:

# Windows (PowerShell)
.\scaffold.ps1 build

# Linux/Mac
./scaffold.sh build

The build pipeline:

  1. Auto-fix code style (PSR12)
  2. Run phpstan static analysis
  3. Install production dependencies
  4. Run PHP-Scoper for namespace isolation
  5. Create versioned ZIP in dist/

Next Steps

  • Add edit functionality with update controller method
  • Implement AJAX delete with nonce verification
  • Add pagination with $wpdb->get_results() + LIMIT/OFFSET
  • Write unit tests for ContactService
  • Add REST API endpoints via rest_api_init