Tutorial: Build a Notes Plugin
In this tutorial, you'll build WP Notes — a complete plugin that lets users create, read, update, and delete notes from the WordPress admin panel. You'll learn every layer of WPZylos: database migrations, models, REST API routing, Vue.js admin UI, and frontend shortcodes.
Time: ~30 minutes | Level: Beginner-friendly | Prerequisites: Quick Start completed
What You'll Build
- 📝 A
notesdatabase table with migrations - 🗃️ A
Notemodel with the WPZylos ORM - 🔌 REST API endpoints for CRUD operations
- 🖥️ A Vue.js admin page to manage notes
- 🌐 A frontend shortcode to display notes
Step 1: Create the Plugin
Start by scaffolding a new plugin:
composer create-project KYNetCode/wpzylos-scaffold wp-notes
cd wp-notes
Initialize it:
# Windows
.scripts/init-plugin.ps1 -PluginName "WP Notes"
# Linux/Mac
bash .scripts/init-plugin.sh --name "WP Notes"
This sets up:
- Slug:
wp-notes - Namespace:
WpNotes - CSS prefix:
wn- - DB prefix:
wpnotes_
Install dependencies:
composer install
npm install
Step 2: Create a Database Migration
WPZylos uses a migration system built on WordPress dbDelta(). Create a migration file:
database/migrations/2024_01_01_000001_create_notes_table.php
<?php
declare(strict_types=1);
use WPZylos\Framework\Migrations\Migration;
/**
* Create the notes table.
*/
class CreateNotesTable extends Migration
{
/**
* Run the migration.
*/
public function up(): void
{
$this->create('wpnotes_notes', [
'id' => 'bigint(20) unsigned NOT NULL AUTO_INCREMENT',
'title' => 'varchar(255) NOT NULL DEFAULT \'\'',
'content' => 'longtext NOT NULL',
'status' => "varchar(20) NOT NULL DEFAULT 'draft'",
'user_id' => 'bigint(20) unsigned NOT NULL DEFAULT 0',
'created_at' => 'datetime NOT NULL DEFAULT CURRENT_TIMESTAMP',
'updated_at' => 'datetime NOT NULL DEFAULT CURRENT_TIMESTAMP',
], [
'PRIMARY KEY (id)',
'KEY status (status)',
'KEY user_id (user_id)',
]);
}
/**
* Reverse the migration.
*/
public function down(): void
{
$this->drop('wpnotes_notes');
}
}
How migrations work
- The
create()helper method builds aCREATE TABLEstatement and runs it throughdbDelta() - Table names passed to
create()get the WordPress prefix automatically (e.g.,wp_wpnotes_notes) - The
charsetCollate()method is called internally to use your database's charset - Migrations run on activation — uncomment the migration line in
Activator.php
Enable migrations on activation
Open app/Lifecycle/Activator.php and uncomment the migration call:
// In the activate() method, change:
// self::runMigrations($context);
// To:
self::runMigrations($context);
Then add the runMigrations method if it doesn't exist:
/**
* Run database migrations.
*
* @param PluginContext $context Plugin context
* @return void
*/
private static function runMigrations(PluginContext $context): void
{
$migrationsPath = $context->path('database/migrations');
if (!is_dir($migrationsPath)) {
return;
}
$files = glob($migrationsPath . '/*.php');
foreach ($files as $file) {
require_once $file;
}
// Migrations will run their up() method via dbDelta
// dbDelta is safe to call multiple times (idempotent)
$migration = new \CreateNotesTable();
$migration->setConnection(
new \WPZylos\Framework\Database\Connection()
);
$migration->up();
}
Step 3: Create the Note Model
WPZylos provides an Active Record ORM. Create the model:
app/Models/Note.php
<?php
declare(strict_types=1);
namespace WpNotes\Models;
use WPZylos\Framework\Model\Model;
/**
* Note model.
*
* @property int $id
* @property string $title
* @property string $content
* @property string $status
* @property int $user_id
* @property string $created_at
* @property string $updated_at
*/
class Note extends Model
{
/**
* The table name (without WordPress prefix).
* Full table name will be: wp_wpnotes_notes
*/
protected static string $table = 'wpnotes_notes';
/**
* Mass-assignable attributes.
*
* @var string[]
*/
protected array $fillable = [
'title',
'content',
'status',
'user_id',
];
/**
* Attribute casts.
*
* @var array<string, string>
*/
protected array $casts = [
'id' => 'int',
'user_id' => 'int',
];
/**
* Auto-manage created_at/updated_at.
*/
protected bool $timestamps = true;
// -------------------------------------------------------------------------
// Scopes
// -------------------------------------------------------------------------
/**
* Scope: only published notes.
*/
public function scopePublished($query)
{
return $query->where('status', 'published');
}
/**
* Scope: notes by a specific user.
*/
public function scopeByUser($query, int $userId)
{
return $query->where('user_id', $userId);
}
}
How the Model works
The WPZylos Model uses the same patterns you might know from Laravel's Eloquent:
// Create a note
$note = Note::create([
'title' => 'My First Note',
'content' => 'Hello from WPZylos!',
'status' => 'published',
'user_id' => get_current_user_id(),
]);
// Find by ID
$note = Note::find(1);
// Query with conditions
$notes = Note::where('status', 'published')->get();
// Update
$note->update(['title' => 'Updated Title']);
// Delete
$note->delete();
// Use scopes
$published = Note::published()->get();
$myNotes = Note::byUser(get_current_user_id())->get();
Register the PSR-4 namespace
Make sure composer.json maps the app/ directory. The scaffold already does this:
{
"autoload": {
"psr-4": {
"WpNotes\\": "app/"
}
}
}
After adding the model, dump the autoloader:
composer dump-autoload
Step 4: Create REST API Routes
WPZylos wraps the WordPress REST API with a fluent router. Create the API routes file:
routes/api.php
<?php
declare(strict_types=1);
use WPZylos\Framework\Routing\RestRouter;
use WpNotes\Http\Controllers\NoteController;
return static function (RestRouter $router): void {
// All routes are registered under: /wp-json/wp-notes/v1/
$router->get('/notes', [NoteController::class, 'index'])
->permission('manage_options')
->name('notes.index');
$router->get('/notes/(?P<id>\d+)', [NoteController::class, 'show'])
->permission('manage_options')
->name('notes.show');
$router->post('/notes', [NoteController::class, 'store'])
->permission('manage_options')
->name('notes.store');
$router->put('/notes/(?P<id>\d+)', [NoteController::class, 'update'])
->permission('manage_options')
->name('notes.update');
$router->delete('/notes/(?P<id>\d+)', [NoteController::class, 'destroy'])
->permission('manage_options')
->name('notes.destroy');
};
How REST routing works
- The
RoutingServiceProviderautomatically loadsroutes/api.phpon boot - Routes are registered under
/wp-json/{plugin-slug}/v1/ - The
->permission()method maps to WordPress'spermission_callback - Route parameters use WordPress REST API regex patterns
Step 5: Create the REST Controller
Create the controller that handles the API requests:
app/Http/Controllers/NoteController.php
<?php
declare(strict_types=1);
namespace WpNotes\Http\Controllers;
use WP_REST_Request;
use WP_REST_Response;
use WpNotes\Models\Note;
/**
* REST API controller for notes.
*/
class NoteController
{
/**
* List all notes.
*
* GET /wp-json/wp-notes/v1/notes
*/
public function index(WP_REST_Request $request): WP_REST_Response
{
$notes = Note::query()
->orderBy('created_at', 'DESC')
->get();
$data = array_map(
fn(object $row) => Note::newFromRow($row)->toArray(),
$notes
);
return new WP_REST_Response([
'success' => true,
'data' => $data,
]);
}
/**
* Show a single note.
*
* GET /wp-json/wp-notes/v1/notes/{id}
*/
public function show(WP_REST_Request $request): WP_REST_Response
{
$id = (int) $request->get_param('id');
$note = Note::find($id);
if ($note === null) {
return new WP_REST_Response([
'success' => false,
'message' => 'Note not found.',
], 404);
}
return new WP_REST_Response([
'success' => true,
'data' => $note->toArray(),
]);
}
/**
* Create a new note.
*
* POST /wp-json/wp-notes/v1/notes
*/
public function store(WP_REST_Request $request): WP_REST_Response
{
$note = Note::create([
'title' => sanitize_text_field($request->get_param('title') ?? ''),
'content' => wp_kses_post($request->get_param('content') ?? ''),
'status' => sanitize_text_field($request->get_param('status') ?? 'draft'),
'user_id' => get_current_user_id(),
]);
return new WP_REST_Response([
'success' => true,
'data' => $note->toArray(),
], 201);
}
/**
* Update a note.
*
* PUT /wp-json/wp-notes/v1/notes/{id}
*/
public function update(WP_REST_Request $request): WP_REST_Response
{
$id = (int) $request->get_param('id');
$note = Note::find($id);
if ($note === null) {
return new WP_REST_Response([
'success' => false,
'message' => 'Note not found.',
], 404);
}
$data = [];
if ($request->get_param('title') !== null) {
$data['title'] = sanitize_text_field($request->get_param('title'));
}
if ($request->get_param('content') !== null) {
$data['content'] = wp_kses_post($request->get_param('content'));
}
if ($request->get_param('status') !== null) {
$data['status'] = sanitize_text_field($request->get_param('status'));
}
$note->update($data);
return new WP_REST_Response([
'success' => true,
'data' => $note->toArray(),
]);
}
/**
* Delete a note.
*
* DELETE /wp-json/wp-notes/v1/notes/{id}
*/
public function destroy(WP_REST_Request $request): WP_REST_Response
{
$id = (int) $request->get_param('id');
$note = Note::find($id);
if ($note === null) {
return new WP_REST_Response([
'success' => false,
'message' => 'Note not found.',
], 404);
}
$note->delete();
return new WP_REST_Response([
'success' => true,
'message' => 'Note deleted.',
]);
}
}
Key points
- Controllers receive
WP_REST_Requestand returnWP_REST_Response— standard WordPress REST API objects - Always sanitize input: use
sanitize_text_field()for plain text,wp_kses_post()for HTML content - The
permission('manage_options')on the route ensures only admins can access these endpoints - WordPress handles nonce verification automatically for REST API requests via the
X-WP-Nonceheader
Step 6: Build the Vue.js Admin Page
Now let's create the admin interface. Replace the default AdminApp component with our notes manager.
6a. Update the Vue Component
resources/js/components/AdminApp.vue
<template>
<div :data-theme="darkMode ? 'dark' : 'light'">
<!-- Header -->
<div class="wn-flex wn-justify-between wn-items-center wn-mb-6">
<h2 class="wn-text-xl wn-font-bold">My Notes</h2>
<div class="wn-flex wn-gap-2">
<button
class="wn-btn wn-btn-primary wn-btn-sm"
@click="showForm = !showForm"
>
{{ showForm ? 'Cancel' : '+ New Note' }}
</button>
<button
class="wn-btn wn-btn-ghost wn-btn-circle wn-btn-sm"
@click="darkMode = !darkMode"
:title="darkMode ? 'Light Mode' : 'Dark Mode'"
>
🌓
</button>
</div>
</div>
<!-- Alert Messages -->
<div v-if="message" class="wn-alert wn-mb-4" :class="messageClass">
<span>{{ message }}</span>
</div>
<!-- Create/Edit Form -->
<div v-if="showForm" class="wn-card wn-bg-base-100 wn-shadow-xl wn-mb-6">
<div class="wn-card-body">
<h3 class="wn-card-title">
{{ editingNote ? 'Edit Note' : 'New Note' }}
</h3>
<div class="wn-form-control wn-w-full wn-mb-4">
<label class="wn-label">
<span class="wn-label-text">Title</span>
</label>
<input
v-model="form.title"
type="text"
class="wn-input wn-input-bordered wn-w-full"
placeholder="Enter note title"
/>
</div>
<div class="wn-form-control wn-w-full wn-mb-4">
<label class="wn-label">
<span class="wn-label-text">Content</span>
</label>
<textarea
v-model="form.content"
class="wn-textarea wn-textarea-bordered wn-w-full"
rows="4"
placeholder="Write your note..."
></textarea>
</div>
<div class="wn-form-control wn-w-full wn-max-w-xs wn-mb-4">
<label class="wn-label">
<span class="wn-label-text">Status</span>
</label>
<select v-model="form.status" class="wn-select wn-select-bordered">
<option value="draft">Draft</option>
<option value="published">Published</option>
</select>
</div>
<div class="wn-flex wn-gap-2">
<button
class="wn-btn wn-btn-primary"
@click="saveNote"
:disabled="saving"
>
<span v-if="saving" class="wn-loading wn-loading-spinner"></span>
{{ editingNote ? 'Update' : 'Create' }}
</button>
<button class="wn-btn wn-btn-ghost" @click="resetForm">Cancel</button>
</div>
</div>
</div>
<!-- Notes List -->
<div class="wn-card wn-bg-base-100 wn-shadow-xl">
<div class="wn-card-body">
<div v-if="loading" class="wn-flex wn-justify-center wn-py-8">
<span class="wn-loading wn-loading-spinner wn-loading-lg"></span>
</div>
<div v-else-if="notes.length === 0" class="wn-text-center wn-py-8 wn-opacity-60">
<p>No notes yet. Click "+ New Note" to create one!</p>
</div>
<div v-else class="wn-overflow-x-auto">
<table class="wn-table wn-table-zebra wn-w-full">
<thead>
<tr>
<th>Title</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="note in notes" :key="note.id">
<td class="wn-font-medium">{{ note.title }}</td>
<td>
<span
class="wn-badge"
:class="note.status === 'published'
? 'wn-badge-success'
: 'wn-badge-warning'"
>
{{ note.status }}
</span>
</td>
<td class="wn-text-sm wn-opacity-70">
{{ formatDate(note.created_at) }}
</td>
<td>
<div class="wn-flex wn-gap-1">
<button
class="wn-btn wn-btn-ghost wn-btn-xs"
@click="editNote(note)"
>Edit</button>
<button
class="wn-btn wn-btn-ghost wn-btn-xs wn-text-error"
@click="deleteNote(note.id)"
>Delete</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'AdminApp',
data() {
return {
darkMode: false,
showForm: false,
loading: true,
saving: false,
message: '',
messageClass: '',
notes: [],
editingNote: null,
form: {
title: '',
content: '',
status: 'draft',
},
};
},
mounted() {
this.fetchNotes();
},
methods: {
/**
* Fetch all notes from the REST API.
*/
fetchNotes() {
this.loading = true;
fetch(this.apiUrl('/notes'), {
headers: this.apiHeaders(),
})
.then(res => res.json())
.then(data => {
this.notes = data.data || [];
this.loading = false;
})
.catch(() => {
this.showMessage('Failed to load notes.', 'error');
this.loading = false;
});
},
/**
* Save (create or update) a note.
*/
saveNote() {
if (!this.form.title.trim()) {
this.showMessage('Title is required.', 'warning');
return;
}
this.saving = true;
const isEditing = this.editingNote !== null;
const url = isEditing
? this.apiUrl('/notes/' + this.editingNote.id)
: this.apiUrl('/notes');
const method = isEditing ? 'PUT' : 'POST';
fetch(url, {
method: method,
headers: {
...this.apiHeaders(),
'Content-Type': 'application/json',
},
body: JSON.stringify(this.form),
})
.then(res => res.json())
.then(data => {
if (data.success) {
this.showMessage(
isEditing ? 'Note updated!' : 'Note created!',
'success'
);
this.resetForm();
this.fetchNotes();
} else {
this.showMessage('Error saving note.', 'error');
}
this.saving = false;
})
.catch(() => {
this.showMessage('Network error.', 'error');
this.saving = false;
});
},
/**
* Load a note into the form for editing.
*/
editNote(note) {
this.editingNote = note;
this.form = {
title: note.title,
content: note.content,
status: note.status,
};
this.showForm = true;
},
/**
* Delete a note.
*/
deleteNote(id) {
if (!confirm('Delete this note?')) {
return;
}
fetch(this.apiUrl('/notes/' + id), {
method: 'DELETE',
headers: this.apiHeaders(),
})
.then(res => res.json())
.then(data => {
if (data.success) {
this.showMessage('Note deleted.', 'success');
this.fetchNotes();
}
});
},
/**
* Reset the form to default state.
*/
resetForm() {
this.form = { title: '', content: '', status: 'draft' };
this.editingNote = null;
this.showForm = false;
},
/**
* Build a REST API URL.
*/
apiUrl(path) {
const base = window.wpNotesData?.restUrl || '/wp-json/wp-notes/v1';
return base + path;
},
/**
* Build headers with the WP REST nonce.
*/
apiHeaders() {
return {
'X-WP-Nonce': window.wpNotesData?.nonce || '',
};
},
/**
* Show a temporary message.
*/
showMessage(text, type) {
this.message = text;
this.messageClass = {
'wn-alert-success': type === 'success',
'wn-alert-error': type === 'error',
'wn-alert-warning': type === 'warning',
};
setTimeout(() => { this.message = ''; }, 3000);
},
/**
* Format a date string for display.
*/
formatDate(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr);
return d.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
},
},
};
</script>
6b. Update the Admin View
The PHP view file passes data to the Vue app. Update the Blade template:
resources/views/admin/settings.blade.php
<div id="wn-admin" class="wrap">
<h1 class="text-2xl font-bold mb-6">{{ $page_title ?? 'WP Notes' }}</h1>
{{-- Vue.js App Mount Point --}}
<div id="wp-notes-admin-app"></div>
{{-- Pass data from PHP to JavaScript --}}
<script>
window.wpNotesData = {
nonce: '{{ wp_create_nonce("wp_rest") }}',
restUrl: '{{ esc_url(rest_url("wp-notes/v1")) }}',
};
</script>
</div>
6c. Update the Admin JS Entry
resources/js/admin.js
import '../css/admin.css';
import { createApp } from 'vue';
import AdminApp from './components/AdminApp.vue';
document.addEventListener('DOMContentLoaded', () => {
const mountEl = document.getElementById('wp-notes-admin-app');
if (mountEl) {
const app = createApp(AdminApp);
app.mount(mountEl);
}
});
6d. Build the Frontend
npm run build
Step 7: Add a Frontend Shortcode
Let's display published notes on the frontend using a shortcode.
app/Http/Shortcodes/NotesShortcode.php
<?php
declare(strict_types=1);
namespace WpNotes\Http\Shortcodes;
use WPZylos\Framework\Core\Contracts\ContextInterface;
use WpNotes\Models\Note;
/**
* [wp_notes] shortcode — displays published notes on the frontend.
*
* Usage:
* [wp_notes] — Show all published notes
* [wp_notes limit="5"] — Show the 5 most recent notes
*/
class NotesShortcode
{
private ContextInterface $context;
public function __construct(ContextInterface $context)
{
$this->context = $context;
}
/**
* Register the shortcode with WordPress.
*/
public function register(): void
{
add_shortcode('wp_notes', [$this, 'render']);
}
/**
* Render the shortcode output.
*
* @param array $atts Shortcode attributes
* @return string HTML output
*/
public function render(array $atts = []): string
{
$atts = shortcode_atts([
'limit' => 10,
], $atts);
$notes = Note::where('status', 'published')
->orderBy('created_at', 'DESC')
->limit((int) $atts['limit'])
->get();
if (empty($notes)) {
return '<p>No notes to display.</p>';
}
$html = '<div class="wn-space-y-4">';
foreach ($notes as $row) {
$note = Note::newFromRow($row);
$title = esc_html($note->title);
$content = wp_kses_post($note->content);
$date = esc_html(
date_i18n(get_option('date_format'), strtotime($note->created_at))
);
$html .= <<<HTML
<div class="wn-card wn-bg-base-100 wn-shadow-md">
<div class="wn-card-body">
<h3 class="wn-card-title">{$title}</h3>
<p class="wn-text-sm wn-opacity-60">{$date}</p>
<div>{$content}</div>
</div>
</div>
HTML;
}
$html .= '</div>';
return $html;
}
}
Register the shortcode
Register it in a service provider. Create or update your AppServiceProvider:
app/Providers/AppServiceProvider.php
<?php
declare(strict_types=1);
namespace WpNotes\Providers;
use WPZylos\Framework\Core\Contracts\ApplicationInterface;
use WPZylos\Framework\Core\ServiceProvider;
use WpNotes\Http\Shortcodes\NotesShortcode;
class AppServiceProvider extends ServiceProvider
{
public function register(ApplicationInterface $app): void
{
parent::register($app);
}
public function boot(ApplicationInterface $app): void
{
// Register shortcodes
$shortcode = new NotesShortcode($app->context());
$shortcode->register();
}
}
Then register this provider in config/app.php:
'providers' => [
\WpNotes\Providers\AppServiceProvider::class,
],
Step 8: Test Everything
Activate the plugin
# Copy/symlink to wp-content/plugins/
wp plugin activate wp-notes
Test the REST API
Open your browser's console or use a tool like Postman:
# List notes
curl -X GET "http://localhost/wp-json/wp-notes/v1/notes" \
-H "X-WP-Nonce: YOUR_NONCE" \
--cookie "wordpress_logged_in_xxx=..."
# Create a note
curl -X POST "http://localhost/wp-json/wp-notes/v1/notes" \
-H "Content-Type: application/json" \
-H "X-WP-Nonce: YOUR_NONCE" \
-d '{"title": "Hello World", "content": "My first note!", "status": "published"}'
Test the admin page
- Go to WordPress Admin → your plugin's settings page
- Click + New Note and fill out the form
- Create a few notes, edit them, and delete one
Test the shortcode
- Create a new WordPress page
- Add the shortcode:
[wp_notes limit="5"] - View the page to see your published notes displayed with DaisyUI cards
Project Structure (Final)
Here's what your completed plugin looks like:
wp-notes/
+-- wp-notes.php # Main plugin file
+-- composer.json
+-- package.json
+-- vite.config.js
+-- bootstrap/
| +-- app.php # Service provider registration
+-- config/
| +-- app.php # App config + provider list
+-- app/
| +-- Core/
| | +-- PluginContext.php
| +-- Http/
| | +-- Controllers/
| | | +-- NoteController.php # ← NEW: REST API controller
| | +-- Shortcodes/
| | +-- ExampleShortcode.php
| | +-- NotesShortcode.php # ← NEW: Frontend shortcode
| +-- Lifecycle/
| | +-- Activator.php # Updated: runs migrations
| | +-- Deactivator.php
| | +-- Uninstaller.php
| +-- Models/
| | +-- Note.php # ← NEW: Note model
| +-- Providers/
| | +-- AppServiceProvider.php # ← NEW: Shortcode registration
| +-- Support/
| +-- helpers.php
+-- database/
| +-- migrations/
| +-- 2024_01_01_000001_create_notes_table.php # ← NEW
+-- resources/
| +-- css/
| | +-- admin.css
| | +-- app.css
| +-- js/
| | +-- admin.js # Updated: mount point
| | +-- app.js
| | +-- components/
| | +-- AdminApp.vue # Updated: Notes CRUD UI
| +-- views/
| +-- admin/
| +-- settings.blade.php # Updated: REST data passing
+-- routes/
| +-- api.php # ← NEW: REST API routes
| +-- web.php
+-- tests/
What You Learned
In this tutorial, you've used all the major layers of WPZylos:
| Layer | What you did |
|---|---|
| Migrations | Created a notes table using dbDelta() |
| Model (ORM) | Built a Note model with fillable attrs, casts, scopes |
| REST Routing | Registered GET/POST/PUT/DELETE endpoints with permission checks |
| Controller | Handled requests with sanitization and JSON responses |
| Vue.js (Options API) | Built a full CRUD admin interface with DaisyUI components |
| Shortcode | Rendered published notes on the frontend |
| Service Provider | Wired everything together via the DI container |
Next Steps
- Configuration — Customize your plugin's behavior per environment
- Architecture Overview — Deep dive into the framework design
- Database Package — Advanced queries, joins, and transactions
- Model Package — Relationships, soft deletes, and events
- Validation — Validate request data with 26 built-in rules
- Security — Rate limiting, sanitization, capability gates
- UI Components — Full DaisyUI component reference and theming