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:
| Prompt | Enter This | Description |
|---|---|---|
| Plugin Name | Contact Manager | Display name |
| Plugin Slug | contact-manager | Lowercase, hyphenated |
| Namespace | ContactManager | PascalCase PHP namespace |
| Scoper Prefix | contact_manager | For PHP-Scoper isolation |
| Database Prefix | cm_ | For DB tables and options |
| Author Name | Your Name | Plugin 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
Activatorclass, 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:
- Auto-fix code style (PSR12)
- Run phpstan static analysis
- Install production dependencies
- Run PHP-Scoper for namespace isolation
- 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
Related
- Package Reference - Core package docs
- Testing Overview - Testing guide
- Routing & Admin Pages - Routing docs