Week 21: Plugin Development II — Custom Tables & the Settings API

Everything the Portfolio Manager plugin stores so far lives in wp_posts and wp_postmeta. This week covers when that's the wrong choice — custom tables via $wpdb — plus a proper admin settings page using WordPress's Settings API, replacing ad-hoc wp_options calls with the framework's own validated, nonce-protected pattern.

Module 18 of 28 Week 21 of 32 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Create & query a custom database table safely with $wpdb
  • Build an admin settings page with the Settings API
  • Validate & sanitize plugin settings on save

1. When to Use a Custom Table

wp_posts/wp_postmeta fit content that behaves like content — has a title, a URL, an author, revisions. Data that doesn't fit that shape — a log of project inquiry form submissions, say, with no need for any of those post features — is a better fit for a custom table, designed the way Week 6 would design one from scratch:

the trade-off
wp_postmeta approach:          Custom table approach:
- No schema to design           - Schema designed to fit the data exactly
- No new table to maintain      - A table to create & version
- Slower for complex queries    - Fast, indexed queries
- Gets messy at scale           - Scales cleanly

A project inquiry log with hundreds of thousands of rows, queried by date range and status, is exactly the case where a purpose-built table with real indexes outperforms — and stays more maintainable than — forcing the data through wp_postmeta.

2. Creating & Querying with $wpdb

$wpdb is WordPress's global database access object — conceptually the same role PDO played in Module 1, wrapped in WordPress-specific conventions:

includes/install.php
<?php

if (!defined('ABSPATH')) exit;

function pm_create_inquiries_table() {
    global $wpdb;

    $table = $wpdb->prefix . 'pm_inquiries'; // respects a custom table prefix, e.g. wp_ or wp2_
    $charset_collate = $wpdb->get_charset_collate();

    $sql = "CREATE TABLE {$table} (
        id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
        project_id BIGINT UNSIGNED NOT NULL,
        name VARCHAR(200) NOT NULL,
        email VARCHAR(255) NOT NULL,
        message TEXT NOT NULL,
        created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
        KEY project_id (project_id)
    ) {$charset_collate};";

    require_once ABSPATH . 'wp-admin/includes/upgrade.php';
    dbDelta($sql); // creates the table, or safely alters it to match on future plugin updates
}
register_activation_hook(PM_PLUGIN_DIR . 'portfolio-manager.php', 'pm_create_inquiries_table');

dbDelta() is WordPress's schema-migration function — it compares the given SQL against the existing table and applies only the difference, which is what makes it safe to call on every activation, not just the first one (the same underlying goal as Week 11's Laravel migrations, achieved differently).

includes/inquiries.php — CRUD with $wpdb
<?php

if (!defined('ABSPATH')) exit;

function pm_save_inquiry(int $projectId, string $name, string $email, string $message): int {
    global $wpdb;

    $wpdb->insert(
        $wpdb->prefix . 'pm_inquiries',
        [
            'project_id' => $projectId,
            'name' => $name,
            'email' => $email,
            'message' => $message,
        ],
        ['%d', '%s', '%s', '%s'] // format specifiers -- int, string, string, string
    );

    return (int) $wpdb->insert_id;
}

function pm_get_inquiries_for_project(int $projectId): array {
    global $wpdb;
    $table = $wpdb->prefix . 'pm_inquiries';

    return $wpdb->get_results(
        $wpdb->prepare("SELECT * FROM {$table} WHERE project_id = %d ORDER BY created_at DESC", $projectId)
    );
}

$wpdb->prepare() is the exact same idea as Week 7's PDO prepared statements — %d/%s placeholders are bound safely, never string-concatenated. $wpdb->insert() is a convenience wrapper that builds a parameterized INSERT for you from an array, with the same underlying safety guarantee.

Never skip $wpdb->prepare() on a query with variables in it

The exact same SQL injection risk from Week 8 applies here, unchanged — $wpdb->get_results("SELECT * FROM table WHERE id = {$id}") is just as vulnerable as raw string-concatenated PDO would be. WordPress's helpers don't make this risk go away automatically; prepare() is what actually closes it, the same way a PDO prepared statement did in Week 7.

3. An Admin Menu Page

A dedicated settings page needs a menu entry, registered on the admin_menu hook:

includes/admin-page.php
<?php

if (!defined('ABSPATH')) exit;

function pm_add_admin_menu() {
    add_menu_page(
        'Portfolio Manager Settings', // page title
        'Portfolio Manager',           // menu label
        'manage_options',              // required capability
        'portfolio-manager',           // menu slug
        'pm_render_settings_page',     // callback that outputs the page
        'dashicons-portfolio'
    );
}
add_action('admin_menu', 'pm_add_admin_menu');

function pm_render_settings_page() {
    ?>
    <div class="wrap">
      <h1>Portfolio Manager Settings</h1>
      <form method="post" action="options.php">
        <?php
        settings_fields('pm_settings_group');
        do_settings_sections('portfolio-manager');
        submit_button();
        ?>
      </form>
    </div>
    <?php
}

'manage_options' is a capability check — only users who can manage site options (administrators, by default) even see this menu item. This is WordPress's authorization mechanism, conceptually parallel to Week 13's Laravel Gates, just checked via a capability string instead of a closure.

4. The Settings API

settings_fields()/do_settings_sections() above are placeholders until fields are registered against them — the Settings API handles rendering, nonce protection, and saving to wp_options all together:

includes/admin-page.php — registering settings
<?php

function pm_register_settings() {
    register_setting('pm_settings_group', 'pm_notification_email', [
        'sanitize_callback' => 'sanitize_email',
        'default' => get_option('admin_email'),
    ]);

    add_settings_section('pm_main_section', 'Notifications', null, 'portfolio-manager');

    add_settings_field(
        'pm_notification_email',
        'Notify this email on new inquiries',
        'pm_render_email_field',
        'portfolio-manager',
        'pm_main_section'
    );
}
add_action('admin_init', 'pm_register_settings');

function pm_render_email_field() {
    $value = get_option('pm_notification_email');
    echo '<input type="email" name="pm_notification_email" value="' . esc_attr($value) . '" class="regular-text">';
}

esc_attr() is WordPress's escaping function for values placed inside an HTML attribute specifically — a slightly different job from esc_html() (Week 19), which is for values placed in an element's text content. Using the wrong one is a subtle bug: esc_html() doesn't escape the quote characters that matter inside an attribute.

5. Sanitizing Settings

'sanitize_callback' => 'sanitize_email' above is doing real work — WordPress runs it automatically on save, before the value ever reaches wp_options:

common sanitize_* functions
sanitize_text_field($input);  // strips tags, extra whitespace -- for plain text
sanitize_email($input);       // validates/cleans an email address
sanitize_textarea_field($input); // like sanitize_text_field, but preserves line breaks
absint($input);                // forces a non-negative integer
esc_url_raw($input);           // sanitizes a URL for storage (esc_url() is for output instead)

This is the direct WordPress-flavored equivalent of Week 10's Laravel Form Request validation rules — a declared, automatically-applied rule per field, run before the value is trusted or persisted, rather than a manual check written inline at every save point.

6. Hands-on Exercise

Hands-on

Add an inquiries table & settings page to Portfolio Manager

A custom table for a real feature, plus an admin settings page controlling it.

Requirements:

  1. Create the {prefix}pm_inquiries table via dbDelta() on plugin activation, with the schema shown above.
  2. Add an inquiry form to single-project.php (name, email, message) that saves via pm_save_inquiry() on submit, with a nonce check and a validated/sanitized email address.
  3. Build the admin settings page with a "notification email" field using the Settings API, including sanitize_email as its sanitize callback.
  4. When a new inquiry is saved, send a notification email (wp_mail()) to whichever address is stored in the pm_notification_email option.
  5. Add a second admin page (or a section on the same one) listing all inquiries for a project via pm_get_inquiries_for_project().
Hint

Use wp_nonce_field() in the inquiry form and wp_verify_nonce() when handling the submission — this is WordPress's version of Week 8's hand-built CSRF token, and the frontend inquiry form is exactly the kind of public-facing write endpoint that needs it.

7. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why would a plugin choose a custom table over storing data in wp_postmeta?

When the data doesn't behave like content — no title, no author, no need for revisions or the rest of what a post row offers — and especially once query patterns (filtering by date range, status, or a foreign key) and data volume matter. A purpose-built table with real column types and indexes, designed the way Week 6 would design one, scales and queries far better than forcing the shape through the generic key/value wp_postmeta pattern.

Q2

What does dbDelta() do differently from a plain CREATE TABLE query?

It compares the SQL you provide against the table as it currently exists and applies only the necessary differences — creating the table if it's missing, or safely adding/adjusting columns if it already exists in an older shape. That's what makes it safe to call on every plugin activation (including updates to an already-installed plugin) rather than only once, unlike a raw CREATE TABLE which would error on a table that already exists.

Q3

Why is $wpdb->get_results("SELECT * FROM table WHERE id = {$id}") just as dangerous as the raw PDO example from Week 8, despite going through $wpdb?

Using $wpdb doesn't automatically make a query safe — the danger comes from string-concatenating a variable directly into SQL, which $wpdb does nothing to prevent on its own. The fix is the same one from Week 7: use $wpdb->prepare() with %d/%s placeholders so the value is bound as data, never re-interpreted as SQL syntax.

Q4

What does setting 'sanitize_callback' => 'sanitize_email' on a registered setting actually guarantee?

WordPress runs that callback on the submitted value automatically, before it's saved to wp_options, every single time this setting is updated through the Settings API form — there's no way to accidentally skip it by forgetting to call it manually. This is the WordPress-flavored equivalent of a Laravel Form Request's validation rules from Week 10: a declared rule enforced structurally rather than something a developer has to remember to check inline.