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 notes database table with migrations
  • 🗃️ A Note model 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 a CREATE TABLE statement and runs it through dbDelta()
  • 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 RoutingServiceProvider automatically loads routes/api.php on boot
  • Routes are registered under /wp-json/{plugin-slug}/v1/
  • The ->permission() method maps to WordPress's permission_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_Request and return WP_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-Nonce header

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

  1. Go to WordPress Admin → your plugin's settings page
  2. Click + New Note and fill out the form
  3. Create a few notes, edit them, and delete one

Test the shortcode

  1. Create a new WordPress page
  2. Add the shortcode: [wp_notes limit="5"]
  3. 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:

LayerWhat you did
MigrationsCreated a notes table using dbDelta()
Model (ORM)Built a Note model with fillable attrs, casts, scopes
REST RoutingRegistered GET/POST/PUT/DELETE endpoints with permission checks
ControllerHandled requests with sanitization and JSON responses
Vue.js (Options API)Built a full CRUD admin interface with DaisyUI components
ShortcodeRendered published notes on the frontend
Service ProviderWired everything together via the DI container

Next Steps