Scaffold Walkthrough

Build a complete WPZylos plugin from scratch.

What We'll Build

A "Task Manager" plugin featuring:

  • Custom database table
  • Admin CRUD interface
  • Nonce + capability protection
  • Validation + sanitization
  • Database migrations
  • PHP-Scoper isolated build

Prerequisites

  • PHP 8.0+
  • WordPress 6.0+
  • Composer 2.0+
  • WP-CLI (optional)

Step 1: Create Plugin

cd /path/to/wordpress/wp-content/plugins
composer create-project MyCompanystudio/wpzylos-scaffold task-manager
cd task-manager

Initialize using the Scaffold CLI:

Windows (PowerShell):

.\scaffold.ps1 init

Linux/Mac:

chmod +x scaffold.sh
./scaffold.sh init

Answer the prompts:

Plugin Name: Task Manager
Plugin Slug: task-manager
PHP Namespace: TaskManager
Scoper Prefix: task_manager
Database Prefix: taskmanager_

Step 2: Review Generated Structure

task-manager/
+-- task-manager.php       # Main plugin file
+-- bootstrap/
|   +-- app.php           # Bootstraps application
+-- config/
|   +-- app.php           # Provider registration
+-- src/
|   +-- Providers/
|       +-- AppServiceProvider.php
+-- database/
|   +-- migrations/       # Database migrations
+-- resources/
|   +-- views/            # Templates
+-- routes/
|   +-- admin.php         # Admin routes
+-- scoper.inc.php        # PHP-Scoper config
+-- composer.json

Step 3: Create Migration

php zylos make:migration create_tasks_table

Edit database/migrations/xxxx_create_tasks_table.php:

<?php

use WPZylos\Framework\Migrations\Migration;

return new class extends Migration
{
    public function up(): void
    {
        $table = $this->context->tableName('tasks');

        $sql = "CREATE TABLE {$table} (
            id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
            title varchar(255) NOT NULL,
            description text,
            status varchar(50) NOT NULL DEFAULT 'pending',
            priority int(11) NOT NULL DEFAULT 0,
            user_id bigint(20) unsigned NOT NULL,
            due_date date DEFAULT NULL,
            created_at datetime DEFAULT CURRENT_TIMESTAMP,
            updated_at datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
            PRIMARY KEY  (id),
            KEY user_id (user_id),
            KEY status (status)
        ) {$this->charset};";

        $this->dbDelta($sql);
    }

    public function down(): void
    {
        $this->dropTable('tasks');
    }
};

Step 4: Create Model & Service

php zylos make:model Task
php zylos make:service TaskService

Edit src/Services/TaskService.php:

<?php

namespace TaskManager\Services;

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

class TaskService
{
    private string $table;

    public function __construct(
        private Connection $db,
        private ContextInterface $context
    ) {
        $this->table = $context->tableName('tasks');
    }

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

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

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

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

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

Step 5: Create Controller

php zylos make:controller TaskController

Edit src/Http/Controllers/TaskController.php:

<?php

namespace TaskManager\Http\Controllers;

use WPZylos\Framework\Views\ViewFactory;
use WPZylos\Framework\Security\Nonce;
use WPZylos\Framework\Security\Gate;
use WPZylos\Framework\Security\Sanitizer;
use WPZylos\Framework\Validation\Validator;
use WPZylos\Framework\Core\Contracts\ContextInterface;
use TaskManager\Services\TaskService;

class TaskController
{
    public function __construct(
        private ViewFactory $view,
        private TaskService $tasks,
        private Nonce $nonce,
        private Gate $gate,
        private Sanitizer $sanitizer,
        private ContextInterface $context
    ) {}

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

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

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

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

        // 2. Check capability
        if (!$this->gate->can('manage_options')) {
            wp_die(__('Unauthorized', $this->context->textDomain()));
        }

        // 3. Validate
        $validator = new Validator();
        $result = $validator->validate($_POST, [
            'title' => 'required|string|max:255',
            'description' => 'nullable|string',
            'status' => 'required|in:pending,in_progress,completed',
            'priority' => 'nullable|numeric|min:0|max:10',
            'due_date' => 'nullable|date',
        ]);

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

        // 4. Sanitize
        $data = [
            'title' => $this->sanitizer->text($_POST['title']),
            'description' => $this->sanitizer->textarea($_POST['description'] ?? ''),
            'status' => $this->sanitizer->text($_POST['status']),
            'priority' => absint($_POST['priority'] ?? 0),
            'due_date' => $this->sanitizer->text($_POST['due_date'] ?? null),
            'user_id' => get_current_user_id(),
        ];

        // 5. Create
        $this->tasks->create($data);

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

Step 6: Register Routes

Edit routes/admin.php:

<?php

use TaskManager\Http\Controllers\TaskController;

/** @var \WPZylos\Framework\Routing\Router $router */

$router->admin('', [TaskController::class, 'index']);
$router->admin('add', [TaskController::class, 'create']);
$router->post('store', [TaskController::class, 'store']);

Step 7: Create Views

Create resources/views/admin/tasks/index.php:

<?php
/** @var string $title */
/** @var array $tasks */
/** @var string $deleteNonce */
/** @var \WPZylos\Framework\Core\Contracts\ContextInterface $context */
?>
<div class="wrap">
    <h1>
        <?php echo esc_html($title); ?>
        <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>
    </h1>

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

    <table class="wp-list-table widefat striped">
        <thead>
            <tr>
                <th><?php esc_html_e('Title', $context->textDomain()); ?></th>
                <th><?php esc_html_e('Status', $context->textDomain()); ?></th>
                <th><?php esc_html_e('Priority', $context->textDomain()); ?></th>
                <th><?php esc_html_e('Due Date', $context->textDomain()); ?></th>
                <th><?php esc_html_e('Actions', $context->textDomain()); ?></th>
            </tr>
        </thead>
        <tbody>
            <?php foreach ($tasks as $task): ?>
            <tr>
                <td><strong><?php echo esc_html($task->title); ?></strong></td>
                <td><?php echo esc_html(ucfirst($task->status)); ?></td>
                <td><?php echo esc_html($task->priority); ?></td>
                <td><?php echo $task->due_date ? esc_html($task->due_date) : '—'; ?></td>
                <td>
                    <a href="<?php echo esc_url(admin_url('admin.php?page=' . $context->slug() . '-edit&id=' . $task->id)); ?>">
                        <?php esc_html_e('Edit', $context->textDomain()); ?>
                    </a>
                </td>
            </tr>
            <?php endforeach; ?>
        </tbody>
    </table>
</div>

Step 8: Run Migrations

wp zylos migrate

Step 9: Activate and Test

wp plugin activate task-manager

Visit WordPress Admin → Task Manager.


Step 10: Build for Production

Use the Scaffold CLI for production builds:

Windows (PowerShell):

.\scaffold.ps1 build

Linux/Mac:

./scaffold.sh build

The build script will:

  1. Clean previous build artifacts
  2. Run code style checks (phpcbf)
  3. Run static analysis (phpstan)
  4. Install production dependencies
  5. Run PHP-Scoper for namespace isolation
  6. Create versioned ZIP in dist/

Step 11: Verify Isolation

Install two instances to verify isolation:

# Copy plugin
cp -r task-manager task-manager-clone

# Edit clone's identity
# - Change slug to task-manager-clone
# - Change prefix to task_clone_
# - Activate both plugins

Both should work independently with separate database tables.


Next Steps

  • Add edit/delete functionality
  • Implement AJAX actions
  • Add REST API endpoints
  • Write unit tests
  • Set up CI/CD