Build a WordPress Enquiry Plugin

Build a WordPress plugin that saves client enquiries in a private dashboard.

Introduction

30 Second Summary

Service enquiries often arrive through scattered inbox messages or contact forms that bury the details a freelancer needs. Turning those messages into a clear follow-up list takes manual work every time.

In this project, you will build LeadFlow as a WordPress plugin for a small service business. It turns each quote request into a private dashboard record.

What You'll Build

You will submit a quote request as a visitor before opening the WordPress dashboard to find the same enquiry ready for review.

By the end of this project, you'll have:

  • A visitor-ready enquiry form that collects a potential client's name, email address, requested service, and project message. Invalid details return clear feedback without creating a lead.
  • A client-friendly lead dashboard that shows each enquiry's service and email address at a glance. Each record opens to reveal the complete request.
  • A portfolio handoff through a public GitHub repository that reviewers can inspect. A downloadable ZIP supports client delivery. A WordPress Playground demo runs in the browser without paid hosting.
  • Secret Mission: Add a honeypot field that quietly discards simple automated spam submissions.

Are there any prerequisites?

Comfort with PHP and object-oriented programming is enough preparation, even if WordPress development is new to you.

Your Mac should already have Docker Desktop, Node.js, and Visual Studio Code installed.

Before We Start

Before any hands-on work, this is your moment to commit to the business problem LeadFlow solves. A small service business can use the plugin to replace manually collecting, organizing, and following up on quote-request enquiries.

Launch Your WordPress Environment

LeadFlow needs a repeatable WordPress workspace before its form can serve a real business. A manual local installation would be difficult to reproduce for future clients.

Git records the identity behind each commit. GitHub connects that identity to the repository you publish later.

Docker supplies repeatable containers. @wordpress/env turns those containers into a local WordPress 7.1 site with LeadFlow mounted as a plugin.

In this step, get ready to:
  • Verify Git plus your GitHub account identity.
  • Create the LeadFlow workspace with its pinned environment configuration.
  • Launch WordPress locally with the empty LeadFlow plugin active.
Verify Git and your GitHub identity

Your future repository needs an author identity that clients can connect to your GitHub profile. Start by confirming that Git is available on your Mac.

  • Press Command-Space to open Spotlight.
  • Type Terminal into Spotlight.
  • Press Return to open Terminal.
  • Check whether Git is installed by running:
git --version

What Does This Command Check?

The command asks Git to report its installed version. A version number confirms that your terminal can use Git for the repository work later in this project.

✔️ I see a version number

Git is ready. Your Mac can now track the LeadFlow source code and its commit history.

ⓧ macOS asks to install Git

The first Git check can trigger the Xcode Command Line Tools installer when Git is missing. This prompt is the normal macOS installation path.

  • Complete the installation prompt shown by macOS.
  • Return to Terminal after the installation finishes.
  • Check Git again by running:
git --version

What Does This Confirm?

A version number now confirms that the Xcode Command Line Tools installation made Git available in Terminal.

Git can record an author locally, but GitHub needs a verified personal account to connect that work to you. Choose the account state that matches your situation.

✔️ I have a verified account

  • Sign in to your existing GitHub account.
  • Confirm that the account uses an email address you have verified.

Your existing account is ready to receive the LeadFlow repository later.

ⓧ I need a verified account

  • Open the GitHub signup page.
  • Create a personal GitHub account by following the signup prompts.
  • Verify the email address using the message GitHub sends you.

Your new account is ready once GitHub recognizes the email address as verified.

Git stores a name and email beside every commit. Use the same identity that you want clients to see on GitHub.

  • Replace Your Name in the first command with your GitHub name.
  • Replace your.email@example.com in the second command with your verified GitHub email.
  • Configure your commit attribution by running the edited commands:
git config --global user.name "Your Name"
git config --global user.email "your.email@example.com"

What Do These Commands Do?

  • The first command sets the author name that Git records in future commits.
  • The second command sets the email that GitHub uses to connect those commits to your account.
  • The --global option applies this identity to repositories created by your macOS user account.

Terminal returns to the prompt without an error when both values are accepted. Your publishing identity is now ready for LeadFlow.

Git Setup Not Working?

If the installation prompt does not finish, complete the macOS update process before checking Git again. If a configuration command fails, confirm that both values remain inside straight double quotes.

Still blocked? Help me troubleshoot my Git installation or global identity configuration on macOS.

Create and pin the workspace

A Visual Studio Code workspace keeps the plugin files inside one named folder. The environment configuration pins the WordPress release plus the local port.

  • Press Command-Space to open Spotlight.
  • Type Visual Studio Code into Spotlight.
  • Press Return to open Visual Studio Code.
  • Select File from the menu bar.
  • Select Open Folder.
  • Select your Desktop folder.
  • Select New Folder.

The folder picker now has a place for the project directory that becomes your VS Code workspace.

  • Name the new folder leadflow-wordpress-plugin.
  • Select Open.
  • Confirm that you trust the folder if VS Code shows a workspace trust prompt.
  • Control-click the leadflow-wordpress-plugin folder in the Explorer.
  • Select New Folder from the context menu.
  • Enter includes as the folder name.

You should see the empty includes directory beneath leadflow-wordpress-plugin in the Explorer. That visible folder confirms the project structure has started.

Why Use wp-env?

A manual WordPress installation can drift as local settings change. wp-env reads one project configuration and recreates the expected WordPress version inside Docker.

This approach makes the workspace easier to restart or hand to another developer. The project configuration becomes the recipe for the local site.

  • Select New File in the Explorer toolbar.
  • Name the file .wp-env.json.
  • Configure the local WordPress environment by copying this code into .wp-env.json:
{"$schema":"https://schemas.wp.org/trunk/wp-env.json","core":"WordPress/WordPress#7.1","plugins":["."],"port":8888}

What Does This Configuration Do?

  • The core value pins the local site to WordPress 7.1.
  • The plugins value mounts the current leadflow-wordpress-plugin folder as an active plugin.
  • The port value exposes the site through port 8888.
  • The $schema value connects the file to the wp-env configuration schema.
  • Save .wp-env.json.

The Explorer should now show .wp-env.json beside the empty includes directory.

The project depends on @wordpress/env version 11.15.0. Checking first reveals whether your machine already has the pinned version.

  • Select Terminal from the VS Code menu bar.
  • Select New Terminal.
  • Check the installed wp-env version by running:
wp-env --version

What Does This Command Check?

The command asks wp-env to print its installed version. The result determines whether you can continue or need the pinned package installation.

✔️ I see the pinned version

You should see 11.15.0. Your wp-env installation matches the project configuration.

ⓧ I see an older version

  • Install the pinned version globally by running these commands:
npm -g i @wordpress/env@11.15.0
wp-env --version

What Do These Commands Do?

The first command installs @wordpress/env version 11.15.0 globally through npm. The second command checks the installed result.

You should see 11.15.0 after npm finishes. The pinned environment tool is now ready.

ⓧ Command not found

Your terminal cannot find wp-env because the package is not installed globally yet.

  • Install the pinned version globally by running these commands:
npm -g i @wordpress/env@11.15.0
wp-env --version

What Do These Commands Do?

The first command adds @wordpress/env version 11.15.0 to your global npm tools. The second command confirms that your terminal can now find it.

You should see 11.15.0 after the installation completes.

wp-env Installation Failing?

Confirm that npm is available in the VS Code terminal. A global installation can also fail when your macOS user cannot write to npm's global package location.

Need a hand? Help me diagnose why the pinned @wordpress/env installation is failing on macOS.

Boot the empty LeadFlow plugin

WordPress recognizes a plugin through the metadata in its main PHP file. This plugin bootstrap also protects direct access and gives the project one version constant.

  • Select New File in the Explorer toolbar.
  • Name the file leadflow.php.
  • Create the empty plugin bootstrap by copying this code into leadflow.php:
<?php
/**
 * Plugin Name: LeadFlow Client Enquiry Manager
 * Description: Captures service enquiries and stores them as private WordPress records.
 * Version: 1.0.0
 * License: GPL-2.0-or-later
 * Text Domain: leadflow
 */

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

define( 'LEADFLOW_VERSION', '1.0.0' );

What Does This Bootstrap Do?

  • The header tells WordPress the plugin name plus its description.
  • The header also records version 1.0.0 plus the project license and text domain.
  • The ABSPATH guard stops the file when someone tries to execute it outside WordPress.
  • The LEADFLOW_VERSION constant gives future plugin components one shared version value.
  • Save leadflow.php.
  • Press Command-Space to open Spotlight.
  • Type Docker Desktop into Spotlight.
  • Press Return to start Docker Desktop.
  • Wait for Docker Desktop to finish starting.

The first wp-env launch can take several minutes while Docker prepares the local environment. A busy terminal during this first launch is expected.

Before you run the command, do you expect WordPress to recognize the folder as an active plugin?

  • Switch back to the integrated terminal in VS Code.
  • Start the local WordPress environment by running:
wp-env start

What Does This Command Start?

wp-env reads .wp-env.json and starts the WordPress containers through Docker. It mounts the current workspace as a plugin and exposes the site on port 8888.

The command keeps the environment tied to this project folder. Future starts use the same pinned configuration.

  • Press Command-Space to open Spotlight after wp-env finishes starting.
  • Type Safari into Spotlight.
  • Press Return to open Safari.
  • Visit http://localhost:8888/wp-admin.
  • Enter admin as the username.
  • Enter password as the password.
  • Submit the WordPress login form.
  • Open the plugin list from the dashboard sidebar.

You should see LeadFlow Client Enquiry Manager listed as active. That confirms WordPress 7.1 loaded the bootstrap from your mounted workspace.

Local Site Not Loading?

Confirm that Docker Desktop is still running. If the terminal mentions port 8888 being unavailable, close the other local process using that port before starting wp-env again.

If the plugin is missing, confirm that .wp-env.json and leadflow.php sit directly inside leadflow-wordpress-plugin.

Still stuck? Help me troubleshoot why wp-env is not loading my local LeadFlow plugin.

✔️ Awesome, I've got everything!

Great. Save both project files and keep the local WordPress environment running for the next step.

The Explorer should show .wp-env.json plus leadflow.php beside the empty includes directory.

ⓧ I'd like to double check the full code

Compare your complete .wp-env.json file with this version:

{"$schema":"https://schemas.wp.org/trunk/wp-env.json","core":"WordPress/WordPress#7.1","plugins":["."],"port":8888}

This complete configuration pins WordPress 7.1 and mounts the current folder on port 8888.

Compare your complete leadflow.php file with this version:

<?php
/**
 * Plugin Name: LeadFlow Client Enquiry Manager
 * Description: Captures service enquiries and stores them as private WordPress records.
 * Version: 1.0.0
 * License: GPL-2.0-or-later
 * Text Domain: leadflow
 */

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

define( 'LEADFLOW_VERSION', '1.0.0' );

This complete bootstrap contains only the plugin header, the direct-access guard, and the version constant. The includes directory remains empty at this stage.

Your pinned WordPress environment is running with LeadFlow mounted as an active plugin. Next, you'll give that empty plugin a private lead model and its first visible enquiry form.

Render the Enquiry Form

LeadFlow is active inside your local WordPress site. However, clients still have no structured lead records or public form to use.

This step gives the plugin a private lead model. It also gives visitors a quote-request form that you will test through a published page.

In this step, get ready to:
  • Register a private lead type with a dashboard interface.
  • Render a styled enquiry form through a shortcode.
  • Publish the Request a Quote page and test the current submission flow.
Register the private lead type

A custom post type gives LeadFlow a structured content model inside WordPress. Its records can remain private while administrators manage them through the dashboard.

Why Use a Custom Post Type?

WordPress already provides content storage and an administration interface for custom post types. LeadFlow can use that infrastructure without adding a separate database schema.

  • Create class-post-type.php inside the existing includes directory in the VS Code Explorer.
  • Copy this first section into includes/class-post-type.php:
<?php

namespace LeadFlow;

final class Post_Type {
	public const TYPE = 'lf_lead';

	public function register_hooks(): void {
		\add_action( 'init', array( $this, 'register' ) );
	}

	public function register(): void {
		\register_post_type(
			self::TYPE,
			array(
				'labels' => array(
					'name'          => 'Leads',
					'singular_name' => 'Lead',
					'menu_name'     => 'Leads',
					'all_items'     => 'All Leads',
					'edit_item'     => 'Review Lead',
					'view_item'     => 'View Lead',
					'search_items'  => 'Search Leads',
					'not_found'     => 'No leads found.',
				),
				'public'              => false,
				'publicly_queryable'  => false,

What Does This Section Do?

  • The TYPE constant gives the lead model one reusable identifier.
  • The init hook schedules the registration method during WordPress initialization.
  • The labels provide clear names for the lead screens in the dashboard.
  • The visibility settings begin by preventing public access to lead records.
  • Complete the open registration array by copying this section directly below the previous code:
				'exclude_from_search' => true,
				'show_ui'             => true,
				'show_in_menu'        => true,
				'show_in_nav_menus'   => false,
				'show_in_admin_bar'   => false,
				'show_in_rest'        => false,
				'has_archive'         => false,
				'rewrite'             => false,
				'capability_type'     => 'post',
				'map_meta_cap'        => true,
				'supports'            => array( 'title' ),
			)
		);
	}
}

How Is the Lead Type Configured?

  • The public-facing settings keep leads out of searches and navigation menus.
  • The dashboard settings expose a Leads interface to administrators.
  • The capability settings reuse WordPress post permissions for the private records.
  • The title support gives each future lead a recognizable record name.
  • In leadflow.php locate the LEADFLOW_VERSION constant.
  • Add this component registration below the constant:
require_once __DIR__ . '/includes/class-post-type.php';

$post_type = new LeadFlow\Post_Type();

$post_type->register_hooks();

What Does This Bootstrap Code Do?

The bootstrap loads the class before creating its object. Calling register_hooks() connects the registration method to WordPress initialization.

  • Save includes/class-post-type.php.
  • Save leadflow.php.
  • Return to the signed-in WordPress dashboard from earlier.
  • Refresh the dashboard page.

You will see Leads in the dashboard menu. That menu confirms WordPress registered the private lf_lead content model.

Leads Missing From the Dashboard?

  • Check that class-post-type.php sits inside the existing includes directory.
  • Check that the path in leadflow.php matches includes/class-post-type.php exactly.
  • Check the bottom of the class for each closing brace.

Still stuck? Help me debug why the Leads menu is missing.

Build the shortcode form

A shortcode lets a normal WordPress page request markup from a plugin. LeadFlow maps [leadflow_form] to a method that returns the quote form.

  • Create class-form.php inside the existing includes directory in the VS Code Explorer.
  • Copy the class setup into includes/class-form.php:
<?php

namespace LeadFlow;

final class Form {
	public function register_hooks(): void {
		\add_action( 'init', array( $this, 'register_shortcode' ) );
	}

	public function register_shortcode(): void {
		\add_shortcode( 'leadflow_form', array( $this, 'render' ) );
	}

	public static function service_labels(): array {
		return array(
			'website-refresh'   => 'Website refresh',
			'plugin-development' => 'Custom plugin development',
			'maintenance'       => 'Ongoing maintenance',
		);
	}

	public function render(): string {
		ob_start();
		?>
		<style>
			.leadflow-card { max-width: 720px; padding: 28px; border: 1px solid #dcdcde; border-radius: 12px; background: #fff; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.06); }
			.leadflow-card h2 { margin-top: 0; }
			.leadflow-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
			.leadflow-field { margin-bottom: 18px; }

What Does the Form Setup Do?

  • The init hook registers the shortcode during WordPress initialization.
  • The service map pairs stable identifiers with labels that visitors can read.
  • Output buffering lets render() collect the markup before returning it.
  • The first style rules define the card width and responsive field grid.
  • Continue the form styles and opening markup by copying this section below the previous code:
			.leadflow-field label { display: block; margin-bottom: 6px; font-weight: 600; }
			.leadflow-field input, .leadflow-field select, .leadflow-field textarea { width: 100%; box-sizing: border-box; padding: 10px 12px; border: 1px solid #8c8f94; border-radius: 6px; }
			.leadflow-button { padding: 11px 18px; border: 0; border-radius: 6px; background: #2271b1; color: #fff; cursor: pointer; }
			.leadflow-notice { margin-bottom: 18px; padding: 12px 14px; border-radius: 6px; }
			.leadflow-notice--success { background: #edfaef; color: #135e1f; }
			.leadflow-notice--error { background: #fcf0f1; color: #8a2424; }
			@media (max-width: 640px) { .leadflow-grid { grid-template-columns: 1fr; } }
		</style>

		<section class="leadflow-card">
			<h2>Request a project quote</h2>
			<p>Tell us what your business needs and we will follow up.</p>

			<form class="leadflow-form" action="<?php echo \esc_url( \admin_url( 'admin-post.php' ) ); ?>" method="post">
				<input type="hidden" name="action" value="leadflow_submit">

				<div class="leadflow-grid">
					<div class="leadflow-field">
						<label for="leadflow_name">Name</label>
						<input id="leadflow_name" name="leadflow_name" type="text" required>
					</div>
					<div class="leadflow-field">
						<label for="leadflow_email">Email</label>
						<input id="leadflow_email" name="leadflow_email" type="email" required>
					</div>
				</div>

How Does the Form Start?

  • The field styles make each control fill its available space.
  • The form submits to admin-post.php through a URL generated by WordPress.
  • The hidden action field identifies the request as leadflow_submit.
  • The grid places the name and email fields beside each other on wider screens.
  • Complete the service field and message field by copying this final section below the previous code:

				<div class="leadflow-field">
					<label for="leadflow_service">Service</label>
					<select id="leadflow_service" name="leadflow_service" required>
						<option value="">Choose a service</option>
						<?php foreach ( self::service_labels() as $value => $label ) : ?>
							<option value="<?php echo \esc_html( $value ); ?>"><?php echo \esc_html( $label ); ?></option>
						<?php endforeach; ?>
					</select>
				</div>

				<div class="leadflow-field">
					<label for="leadflow_message">Project message</label>
					<textarea id="leadflow_message" name="leadflow_message" rows="6" required></textarea>
				</div>

				<button class="leadflow-button" type="submit">Send enquiry</button>
			</form>
		</section>
		<?php
		return (string) ob_get_clean();
	}
}

How Is the Form Completed?

  • The service menu builds its options from service_labels().
  • The message field gives visitors space to describe their project.
  • The button submits all required values to the form target.
  • The return statement passes the buffered markup back to the shortcode system.
  • In leadflow.php find this component registration:
require_once __DIR__ . '/includes/class-post-type.php';

$post_type = new LeadFlow\Post_Type();

$post_type->register_hooks();

What Is Registered So Far?

The current bootstrap loads the private lead type. The form class needs the same loading and hook-registration sequence.

  • Replace that registration with this expanded version:
require_once __DIR__ . '/includes/class-post-type.php';
require_once __DIR__ . '/includes/class-form.php';

$post_type = new LeadFlow\Post_Type();
$form      = new LeadFlow\Form();

$post_type->register_hooks();
$form->register_hooks();

How Does LeadFlow Load the Form?

Each class file loads before its object is created. Both objects register their WordPress hooks from the central plugin bootstrap.

  • Save includes/class-form.php.
  • Save leadflow.php.
  • Return to the signed-in WordPress dashboard.
  • Refresh the dashboard page.

The dashboard loads normally with Leads still available. This confirms the form class loads without interrupting the plugin.

Dashboard Showing a PHP Error?

  • Check that all three form sections appear inside the same Form class.
  • Check the end of class-form.php for the closing braces belonging to render() and the class.
  • Check that the bootstrap path uses includes/class-form.php exactly.

Need another pair of eyes? Help me find the loading error in my Form class.

✔️ Awesome, I've got everything!

Your private lead model and shortcode class are registered. Make sure both class files and leadflow.php are saved.

ⓧ I'd like to double check the full code

Compare each cumulative file with your workspace.

{"$schema":"https://schemas.wp.org/trunk/wp-env.json","core":"WordPress/WordPress#7.1","plugins":["."],"port":8888}

This configuration keeps the current directory mounted as a plugin in the WordPress 7.1 environment.

<?php

namespace LeadFlow;

final class Post_Type {
	public const TYPE = 'lf_lead';

	public function register_hooks(): void {
		\add_action( 'init', array( $this, 'register' ) );
	}

	public function register(): void {
		\register_post_type(
			self::TYPE,
			array(
				'labels' => array(
					'name'          => 'Leads',
					'singular_name' => 'Lead',
					'menu_name'     => 'Leads',
					'all_items'     => 'All Leads',
					'edit_item'     => 'Review Lead',
					'view_item'     => 'View Lead',
					'search_items'  => 'Search Leads',
					'not_found'     => 'No leads found.',
				),
				'public'              => false,
				'publicly_queryable'  => false,
				'exclude_from_search' => true,
				'show_ui'             => true,
				'show_in_menu'        => true,
				'show_in_nav_menus'   => false,
				'show_in_admin_bar'   => false,
				'show_in_rest'        => false,
				'has_archive'         => false,
				'rewrite'             => false,
				'capability_type'     => 'post',
				'map_meta_cap'        => true,
				'supports'            => array( 'title' ),
			)
		);
	}
}

This file defines the private lf_lead content model and its dashboard labels.

<?php

namespace LeadFlow;

final class Form {
	public function register_hooks(): void {
		\add_action( 'init', array( $this, 'register_shortcode' ) );
	}

	public function register_shortcode(): void {
		\add_shortcode( 'leadflow_form', array( $this, 'render' ) );
	}

	public static function service_labels(): array {
		return array(
			'website-refresh'   => 'Website refresh',
			'plugin-development' => 'Custom plugin development',
			'maintenance'       => 'Ongoing maintenance',
		);
	}

	public function render(): string {
		ob_start();
		?>
		<style>
			.leadflow-card { max-width: 720px; padding: 28px; border: 1px solid #dcdcde; border-radius: 12px; background: #fff; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.06); }
			.leadflow-card h2 { margin-top: 0; }
			.leadflow-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
			.leadflow-field { margin-bottom: 18px; }
			.leadflow-field label { display: block; margin-bottom: 6px; font-weight: 600; }
			.leadflow-field input, .leadflow-field select, .leadflow-field textarea { width: 100%; box-sizing: border-box; padding: 10px 12px; border: 1px solid #8c8f94; border-radius: 6px; }
			.leadflow-button { padding: 11px 18px; border: 0; border-radius: 6px; background: #2271b1; color: #fff; cursor: pointer; }
			.leadflow-notice { margin-bottom: 18px; padding: 12px 14px; border-radius: 6px; }
			.leadflow-notice--success { background: #edfaef; color: #135e1f; }
			.leadflow-notice--error { background: #fcf0f1; color: #8a2424; }
			@media (max-width: 640px) { .leadflow-grid { grid-template-columns: 1fr; } }
		</style>

		<section class="leadflow-card">
			<h2>Request a project quote</h2>
			<p>Tell us what your business needs and we will follow up.</p>

			<form class="leadflow-form" action="<?php echo \esc_url( \admin_url( 'admin-post.php' ) ); ?>" method="post">
				<input type="hidden" name="action" value="leadflow_submit">

				<div class="leadflow-grid">
					<div class="leadflow-field">
						<label for="leadflow_name">Name</label>
						<input id="leadflow_name" name="leadflow_name" type="text" required>
					</div>
					<div class="leadflow-field">
						<label for="leadflow_email">Email</label>
						<input id="leadflow_email" name="leadflow_email" type="email" required>
					</div>
				</div>

				<div class="leadflow-field">
					<label for="leadflow_service">Service</label>
					<select id="leadflow_service" name="leadflow_service" required>
						<option value="">Choose a service</option>
						<?php foreach ( self::service_labels() as $value => $label ) : ?>
							<option value="<?php echo \esc_html( $value ); ?>"><?php echo \esc_html( $label ); ?></option>
						<?php endforeach; ?>
					</select>
				</div>

				<div class="leadflow-field">
					<label for="leadflow_message">Project message</label>
					<textarea id="leadflow_message" name="leadflow_message" rows="6" required></textarea>
				</div>

				<button class="leadflow-button" type="submit">Send enquiry</button>
			</form>
		</section>
		<?php
		return (string) ob_get_clean();
	}
}

This file registers [leadflow_form] and returns the complete styled form.

<?php
/**
 * Plugin Name: LeadFlow Client Enquiry Manager
 * Description: Captures service enquiries and stores them as private WordPress records.
 * Version: 1.0.0
 * License: GPL-2.0-or-later
 * Text Domain: leadflow
 */

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

define( 'LEADFLOW_VERSION', '1.0.0' );

require_once __DIR__ . '/includes/class-post-type.php';
require_once __DIR__ . '/includes/class-form.php';

$post_type = new LeadFlow\Post_Type();
$form      = new LeadFlow\Form();

$post_type->register_hooks();
$form->register_hooks();

This bootstrap loads both component classes and registers their hooks.

Expose the missing request handler

The shortcode needs a published page before visitors can use it. That page also gives you a concrete place to test how the current request flow behaves.

  • Switch back to the terminal for the running leadflow-wordpress-plugin workspace.
  • Create the published Request a Quote page by running this command:
wp-env run cli wp post create --post_type=page --post_title="Request a Quote" --post_name=request-a-quote --post_content="[leadflow_form]" --post_status=publish

What Does This Command Do?

The command runs the page-creation request inside the existing cli container. It publishes a page whose content is the [leadflow_form] shortcode.

Your terminal reports that WordPress created the page. The new record now connects the public URL to LeadFlow's shortcode.

Page Creation Failed?

  • Check that the local environment is still running.
  • Check that the terminal is inside the leadflow-wordpress-plugin directory.
  • Check that every quotation mark in the command has a matching partner.

Need help with the command? Help me troubleshoot the page creation failure.

  • Open http://localhost:8888/request-a-quote/ in your browser.

You will see a white quote-request card with fields for a name and email address. You will also see a service menu and project-message field above the blue Send enquiry button.

That visible form confirms the mounted plugin can control a normal WordPress page. LeadFlow now has its first public interface.

Before you submit the form, do you think this version will create a private lead?

  • Enter Taylor Morgan in the Name field.
  • Enter taylor@example.com in the Email field.
  • Select Custom plugin development from the Service menu.
  • Enter We need a booking workflow for our service website. in the Project message field.
  • Click Send enquiry.

You see no LeadFlow success notice after the request (you may see a blank screen with an empty white rectangle, where the title is "WordPress > Error"). This shortfall is intentional because the plugin has rendered the form without processing its submitted values.

  • Return to http://localhost:8888/wp-admin.
  • Select Leads from the dashboard menu.

The lead table remains empty. This proves the shortcode can render an interface without creating a request handler.

Why Did the Submission Stop?

The form identifies its request as leadflow_submit. LeadFlow currently registers only the shortcode hook.

WordPress has no LeadFlow callback for that submitted action. The next step connects the request hooks and turns valid submissions into private leads.

You have a private content model and a public enquiry form. Next, you will connect the submission flow so valid enquiries become private lead records.

Validate and Store Enquiries

Your LeadFlow form reaches WordPress, but the request still has no callback to process it. The enquiry disappears before it can become a private lead.

This step connects both visitor states through WordPress action hooks. You will verify each request before validating its values and storing the enquiry.

In this step, get ready to:
  • Connect logged-in and public form submissions to one handler.
  • Verify the form token before sanitizing each submitted value.
  • Store valid enquiries as private lead records and show clear feedback.
Connect both submission paths

WordPress provides separate hooks for authenticated visitors and anonymous visitors. Both hooks can point to the same handle_submission() method because the enquiry workflow is identical.

A nonce proves that the request came from the LeadFlow form. It checks intent before the handler processes any submitted values.

  • In includes/class-form.php, replace the existing register_hooks() method with this version:
	public function register_hooks(): void {
		\add_action( 'init', array( $this, 'register_shortcode' ) );
		\add_action( 'admin_post_leadflow_submit', array( $this, 'handle_submission' ) );
		\add_action( 'admin_post_nopriv_leadflow_submit', array( $this, 'handle_submission' ) );
	}

What Do These Hooks Do?

  • The admin_post_leadflow_submit hook catches submissions from visitors who are signed in.
  • The admin_post_nopriv_leadflow_submit hook catches submissions from public visitors.
  • Both hooks use the form's action=leadflow_submit value to select handle_submission() as the callback.
  • In render(), locate the hidden input whose name is action.
  • Add the nonce field directly below that input:
				<?php echo \wp_nonce_field( 'leadflow_submit', 'leadflow_nonce', true, false ); ?>

What Does This Line Do?

The helper generates hidden form markup containing a token named leadflow_nonce. Returning the markup lets the existing output buffer include it in the shortcode response.

The token confirms request intent. Anonymous visitors still need separate validation because a nonce does not authenticate them.

  • Save includes/class-form.php.
  • Refresh http://localhost:8888/request-a-quote/.
  • Use your browser's page-source view to search for leadflow_nonce.

You should find a generated hidden input named leadflow_nonce. Your public form can now send an intent token to the handler.

Cannot Find the Nonce Field?

  • Check that the nonce line sits inside the form immediately below the hidden action input.
  • Confirm that you saved includes/class-form.php before refreshing the page.

Still missing the field? Help me check why the LeadFlow nonce is absent from the rendered form.

Validate the request before storage

Sanitization normalizes each submitted value into a safer format. Validation then decides whether those normalized values satisfy LeadFlow's rules.

The handler also needs one redirect helper for every outcome. Keeping that logic in one method gives success and failure paths the same safe return route.

  • Add this private redirect helper above the final closing brace of the Form class:
	private function redirect( string $status ): void {
		$url = \wp_get_referer();

		if ( ! $url ) {
			$url = \home_url( '/' );
		}

		\wp_safe_redirect( \add_query_arg( 'leadflow_status', $status, $url ) );
		exit;
	}

How Does the Redirect Stay Safe?

  • The helper returns the visitor to the validated referring page when that address is available.
  • The home URL provides a local fallback when the referring address is missing.
  • The leadflow_status query value carries the result back to the shortcode.
  • The final exit stops PHP immediately after WordPress sends the redirect.
  • Add the first part of handle_submission() directly above redirect(). Keep the file unsaved until the next chunk completes the method:
	public function handle_submission(): void {
		if ( ! isset( $_POST['leadflow_nonce'] ) ) {
			\wp_die( 'The form token is missing.', 'Invalid request', 403 );
		}

		$nonce = \sanitize_text_field( \wp_unslash( $_POST['leadflow_nonce'] ) );

		if ( ! \wp_verify_nonce( $nonce, 'leadflow_submit' ) ) {
			\wp_die( 'The form token is invalid.', 'Invalid request', 403 );
		}

		$input    = \wp_unslash( $_POST );
		$name     = isset( $input['leadflow_name'] ) ? \sanitize_text_field( $input['leadflow_name'] ) : '';
		$email    = isset( $input['leadflow_email'] ) ? \sanitize_email( $input['leadflow_email'] ) : '';
		$service  = isset( $input['leadflow_service'] ) ? \sanitize_text_field( $input['leadflow_service'] ) : '';
		$message  = isset( $input['leadflow_message'] ) ? \sanitize_textarea_field( $input['leadflow_message'] ) : '';
		$services = self::service_labels();

		if ( '' === $name || ! \is_email( $email ) || ! isset( $services[ $service ] ) || '' === $message ) {
			$this->redirect( 'invalid' );
		}

How Is the Request Checked?

  • The first guard rejects requests without leadflow_nonce.
  • The second guard rejects tokens that do not match the form action.
  • The submitted array is unslashed before each field receives type-specific sanitization.
  • The final condition requires a name and message. It also requires a valid email plus a service key from service_labels().
  • Complete the same unsaved method by placing this chunk immediately below the previous one:

		$post_id = \wp_insert_post(
			array(
				'post_title'  => \wp_strip_all_tags( \sprintf( '%s - %s', $name, $services[ $service ] ) ),
				'post_type'   => Post_Type::TYPE,
				'post_status' => 'private',
				'meta_input'  => array(
					'_leadflow_name'    => $name,
					'_leadflow_email'   => $email,
					'_leadflow_service' => $service,
					'_leadflow_message' => $message,
				),
			),
			true
		);

		if ( \is_wp_error( $post_id ) ) {
			$this->redirect( 'error' );
		}

		$this->redirect( 'success' );
	}

How Is the Lead Stored?

  • The post title combines the sanitized name with the selected service label.
  • The Post_Type::TYPE constant stores the enquiry as an lf_lead record.
  • The private status keeps the record away from public content queries.
  • The metadata preserves the four normalized enquiry values for the dashboard workflow.
  • Save includes/class-form.php.

Before you submit the form, do you expect the Leads count to stay unchanged or increase?

  • Return to http://localhost:8888/request-a-quote/.
  • Enter Taylor Morgan in the Name field.
  • Enter taylor@example.com in the Email field.
  • Select Website refresh in the Service field.
  • Enter We need a faster service website. in the Project message field.
  • Click Send enquiry.
  • Switch back to your signed-in WordPress dashboard.
  • Select Leads from the dashboard menu.

You should see a private lead titled Taylor Morgan - Website refresh. The missing request handler now creates a structured dashboard record.

Do Not See the New Lead?

  • Check that both submission hooks use handle_submission as their callback.
  • Confirm that both chunks of handle_submission() sit inside the Form class.
  • Check that the form still sends action=leadflow_submit.

Need a second pair of eyes? Help me debug why a valid LeadFlow submission does not create an lf_lead record.

Show the submission result

The redirect already carries a status value back to the form page. The shortcode now needs to sanitize that value before choosing a success or error notice.

  • In render(), add this status lookup immediately before ob_start():
		$status = isset( $_GET['leadflow_status'] )
			? \sanitize_text_field( \wp_unslash( $_GET['leadflow_status'] ) )
			: '';

Why Sanitize the Status?

Query values arrive from the request. Unsplashing and sanitizing leadflow_status gives the shortcode a normalized value before it chooses any output.

  • In the rendered card markup, locate the introductory paragraph below Request a project quote.
  • Add these result notices directly below that paragraph:
			<?php if ( 'success' === $status ) : ?>
				<p class="leadflow-notice leadflow-notice--success">Thanks. Your enquiry has been recorded.</p>
			<?php elseif ( 'invalid' === $status ) : ?>
				<p class="leadflow-notice leadflow-notice--error">Please complete every field with a valid email address.</p>
			<?php elseif ( 'error' === $status ) : ?>
				<p class="leadflow-notice leadflow-notice--error">The enquiry could not be saved. Please try again.</p>
			<?php endif; ?>

How Does the Feedback Work?

  • The success branch confirms that WordPress stored the enquiry.
  • The invalid branch explains that the submitted values failed LeadFlow's rules.
  • The error branch covers a failed post insertion.
  • Save includes/class-form.php.
  • Open a private browser window at http://localhost:8888/request-a-quote/.
  • Enter Jamie Chen in the Name field.
  • Enter jamie@example.com in the Email field.
  • Select Website refresh in the Service field.
  • Enter We need a mobile-friendly service website. in the Project message field.

Before you submit, what feedback do you expect the public visitor to see after WordPress stores the enquiry?

  • Click Send enquiry.

You should return to the form with Thanks. Your enquiry has been recorded. above it. This private-window test proves that anonymous submissions use the public handler.

  • Return to the signed-in WordPress dashboard.
  • Select Leads from the dashboard menu.

You should see the Leads count increase again. You should also see a private record titled Jamie Chen - Website refresh.

Missing the Success Notice?

  • Check that render() reads leadflow_status before starting the output buffer.
  • Confirm that the success branch compares $status with success.
  • Check that redirect() adds the status to the local result URL.

Still seeing the form without feedback? Help me trace why LeadFlow stores a lead but does not render the success notice.

✔️ Awesome, I've got everything!

Great work. Your public form now validates requests and stores private enquiries with visible feedback.

ⓧ I'd like to double check the full code

Compare your includes/class-form.php file with this completed version.

<?php

namespace LeadFlow;

final class Form {
	public function register_hooks(): void {
		\add_action( 'init', array( $this, 'register_shortcode' ) );
		\add_action( 'admin_post_leadflow_submit', array( $this, 'handle_submission' ) );
		\add_action( 'admin_post_nopriv_leadflow_submit', array( $this, 'handle_submission' ) );
	}

	public function register_shortcode(): void {
		\add_shortcode( 'leadflow_form', array( $this, 'render' ) );
	}

	public static function service_labels(): array {
		return array(
			'website-refresh'   => 'Website refresh',
			'plugin-development' => 'Custom plugin development',
			'maintenance'       => 'Ongoing maintenance',
		);
	}

	public function render(): string {
		$status = isset( $_GET['leadflow_status'] )
			? \sanitize_text_field( \wp_unslash( $_GET['leadflow_status'] ) )
			: '';

		ob_start();
		?>
		<style>
			.leadflow-card { max-width: 720px; padding: 28px; border: 1px solid #dcdcde; border-radius: 12px; background: #fff; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.06); }
			.leadflow-card h2 { margin-top: 0; }
			.leadflow-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
			.leadflow-field { margin-bottom: 18px; }
			.leadflow-field label { display: block; margin-bottom: 6px; font-weight: 600; }
			.leadflow-field input, .leadflow-field select, .leadflow-field textarea { width: 100%; box-sizing: border-box; padding: 10px 12px; border: 1px solid #8c8f94; border-radius: 6px; }
			.leadflow-button { padding: 11px 18px; border: 0; border-radius: 6px; background: #2271b1; color: #fff; cursor: pointer; }
			.leadflow-notice { margin-bottom: 18px; padding: 12px 14px; border-radius: 6px; }
			.leadflow-notice--success { background: #edfaef; color: #135e1f; }
			.leadflow-notice--error { background: #fcf0f1; color: #8a2424; }
			@media (max-width: 640px) { .leadflow-grid { grid-template-columns: 1fr; } }
		</style>

		<section class="leadflow-card">
			<h2>Request a project quote</h2>
			<p>Tell us what your business needs and we will follow up.</p>

			<?php if ( 'success' === $status ) : ?>
				<p class="leadflow-notice leadflow-notice--success">Thanks. Your enquiry has been recorded.</p>
			<?php elseif ( 'invalid' === $status ) : ?>
				<p class="leadflow-notice leadflow-notice--error">Please complete every field with a valid email address.</p>
			<?php elseif ( 'error' === $status ) : ?>
				<p class="leadflow-notice leadflow-notice--error">The enquiry could not be saved. Please try again.</p>
			<?php endif; ?>

			<form class="leadflow-form" action="<?php echo \esc_url( \admin_url( 'admin-post.php' ) ); ?>" method="post">
				<input type="hidden" name="action" value="leadflow_submit">
				<?php echo \wp_nonce_field( 'leadflow_submit', 'leadflow_nonce', true, false ); ?>

				<div class="leadflow-grid">
					<div class="leadflow-field">
						<label for="leadflow_name">Name</label>
						<input id="leadflow_name" name="leadflow_name" type="text" required>
					</div>
					<div class="leadflow-field">
						<label for="leadflow_email">Email</label>
						<input id="leadflow_email" name="leadflow_email" type="email" required>
					</div>
				</div>

				<div class="leadflow-field">
					<label for="leadflow_service">Service</label>
					<select id="leadflow_service" name="leadflow_service" required>
						<option value="">Choose a service</option>
						<?php foreach ( self::service_labels() as $value => $label ) : ?>
							<option value="<?php echo \esc_html( $value ); ?>"><?php echo \esc_html( $label ); ?></option>
						<?php endforeach; ?>
					</select>
				</div>

				<div class="leadflow-field">
					<label for="leadflow_message">Project message</label>
					<textarea id="leadflow_message" name="leadflow_message" rows="6" required></textarea>
				</div>

				<button class="leadflow-button" type="submit">Send enquiry</button>
			</form>
		</section>
		<?php
		return (string) ob_get_clean();
	}

	public function handle_submission(): void {
		if ( ! isset( $_POST['leadflow_nonce'] ) ) {
			\wp_die( 'The form token is missing.', 'Invalid request', 403 );
		}

		$nonce = \sanitize_text_field( \wp_unslash( $_POST['leadflow_nonce'] ) );

		if ( ! \wp_verify_nonce( $nonce, 'leadflow_submit' ) ) {
			\wp_die( 'The form token is invalid.', 'Invalid request', 403 );
		}

		$input    = \wp_unslash( $_POST );
		$name     = isset( $input['leadflow_name'] ) ? \sanitize_text_field( $input['leadflow_name'] ) : '';
		$email    = isset( $input['leadflow_email'] ) ? \sanitize_email( $input['leadflow_email'] ) : '';
		$service  = isset( $input['leadflow_service'] ) ? \sanitize_text_field( $input['leadflow_service'] ) : '';
		$message  = isset( $input['leadflow_message'] ) ? \sanitize_textarea_field( $input['leadflow_message'] ) : '';
		$services = self::service_labels();

		if ( '' === $name || ! \is_email( $email ) || ! isset( $services[ $service ] ) || '' === $message ) {
			$this->redirect( 'invalid' );
		}

		$post_id = \wp_insert_post(
			array(
				'post_title'  => \wp_strip_all_tags( \sprintf( '%s - %s', $name, $services[ $service ] ) ),
				'post_type'   => Post_Type::TYPE,
				'post_status' => 'private',
				'meta_input'  => array(
					'_leadflow_name'    => $name,
					'_leadflow_email'   => $email,
					'_leadflow_service' => $service,
					'_leadflow_message' => $message,
				),
			),
			true
		);

		if ( \is_wp_error( $post_id ) ) {
			$this->redirect( 'error' );
		}

		$this->redirect( 'success' );
	}

	private function redirect( string $status ): void {
		$url = \wp_get_referer();

		if ( ! $url ) {
			$url = \home_url( '/' );
		}

		\wp_safe_redirect( \add_query_arg( 'leadflow_status', $status, $url ) );
		exit;
	}
}

That is the request pipeline complete. Next up, you will turn these private records into a dashboard workflow a client can scan and review.

Build the Lead Review Workflow

LeadFlow already turns submitted forms into private lead records. Those records need a useful review surface before a client can act on them efficiently.

WordPress hooks let your plugin reshape the existing dashboard without replacing it. You'll add scan-friendly columns to the lead list. You'll also add a read-only view for complete enquiry details.

In this step, get ready to:
  • Add Service and Email columns to the lead list.
  • Add an Enquiry Details box for reviewing every stored value.
Make the lead list scannable

Each lead already stores its service and email address as post metadata. Custom columns bring those values into the list so a client can scan enquiries without opening every record.

  • Switch back to the leadflow-wordpress-plugin folder in Visual Studio Code.
  • Use the editor's new-file control to create class-admin.php inside the includes directory.
  • Build the admin class and define its column order by pasting this code into includes/class-admin.php:
<?php

namespace LeadFlow;

final class Admin {
	public function register_hooks(): void {
		\add_filter( 'manage_' . Post_Type::TYPE . '_posts_columns', array( $this, 'columns' ) );
		\add_action( 'manage_' . Post_Type::TYPE . '_posts_custom_column', array( $this, 'column_content' ), 10, 2 );
		\add_action( 'add_meta_boxes', array( $this, 'add_details_box' ) );
	}

	public function columns( array $columns ): array {
		$updated = array();

		foreach ( $columns as $key => $label ) {
			if ( 'date' === $key ) {
				$updated['leadflow_service'] = 'Service';
				$updated['leadflow_email']   = 'Email';
			}

			$updated[ $key ] = $label;
		}

		return $updated;
	}
}

What does this code do?

  • The register_hooks() method connects the class to the lead list and edit screens.
  • The columns() method rebuilds the existing column array while preserving WordPress's original entries.
  • The leadflow_service and leadflow_email keys are inserted immediately before date.
  • Save includes/class-admin.php.
  • Confirm class-admin.php is listed inside the includes directory in your editor.

Can't find the admin class?

Check that class-admin.php sits inside includes. A file beside leadflow.php has the wrong path.

Still stuck? Help me check where my LeadFlow admin class should be saved.

Column headings only define the table structure. The next method supplies an escaped value whenever WordPress renders one of the new cells.

  • In includes/class-admin.php place your cursor immediately above the final closing brace.
  • Fill the Service and Email cells by inserting this method:
	public function column_content( string $column, int $post_id ): void {
		if ( 'leadflow_service' === $column ) {
			$service  = \get_post_meta( $post_id, '_leadflow_service', true );
			$services = Form::service_labels();
			echo \esc_html( $services[ $service ] ?? $service );
		}

		if ( 'leadflow_email' === $column ) {
			echo \esc_html( \get_post_meta( $post_id, '_leadflow_email', true ) );
		}
	}

How are the cells populated?

  • The $column value identifies which custom cell WordPress is currently rendering.
  • The service identifier is mapped through Form::service_labels() so the client sees a readable label.
  • Each value passes through esc_html() before reaching the dashboard page.
  • Save includes/class-admin.php.
  • Confirm column_content() appears inside the Admin class above its final closing brace.

Is the method outside the class?

Check the closing braces around columns(). The new method belongs after that method closes but before the final brace that closes Admin.

Need another pair of eyes? Help me place column_content() inside my LeadFlow Admin class.

The plugin bootstrap controls which classes WordPress loads. Adding the admin class there activates its hooks on every request.

  • Switch to leadflow.php in your editor.
  • Load and register the admin class by replacing the file contents with this code:
<?php
/**
 * Plugin Name: LeadFlow Client Enquiry Manager
 * Description: Captures service enquiries and stores them as private WordPress records.
 * Version: 1.0.0
 * License: GPL-2.0-or-later
 * Text Domain: leadflow
 */

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

define( 'LEADFLOW_VERSION', '1.0.0' );

require_once __DIR__ . '/includes/class-post-type.php';
require_once __DIR__ . '/includes/class-form.php';
require_once __DIR__ . '/includes/class-admin.php';

$post_type = new LeadFlow\Post_Type();
$form      = new LeadFlow\Form();
$admin     = new LeadFlow\Admin();

$post_type->register_hooks();
$form->register_hooks();
$admin->register_hooks();

How does the bootstrap activate the workflow?

  • The new require_once statement loads includes/class-admin.php.
  • The $admin variable holds an instance of LeadFlow\Admin.
  • The final register_hooks() call attaches the admin callbacks to WordPress.
  • Save leadflow.php.

Before you check the lead list, which two stored values do you expect to see without opening a record?

  • Return to the WordPress dashboard from earlier.
  • Select Leads in the dashboard menu.
  • Open All Leads to load the lead table.

You'll see Service and Email immediately before Date. Existing leads show their readable service labels and email addresses.

That is the review queue taking shape. A client can now identify each enquiry from the list instead of opening records blindly.

Don't see the new columns?

  • Confirm leadflow.php requires includes/class-admin.php.
  • Confirm the $admin instance calls register_hooks().
  • Check that column_content() uses the metadata keys beginning with _leadflow_.

Still missing the columns? Help me debug why my LeadFlow Service and Email columns are not appearing.

Add a read-only detail view

The list provides a quick summary. A dedicated meta box gives the client a safe place to read every value from the selected enquiry.

  • Switch back to includes/class-admin.php.
  • Place your cursor immediately above the final closing brace.
  • Register the Enquiry Details box by inserting this method:
	public function add_details_box(): void {
		\add_meta_box(
			'leadflow-enquiry-details',
			'Enquiry Details',
			array( $this, 'render_details' ),
			Post_Type::TYPE,
			'normal',
			'high'
		);
	}

What does this method configure?

  • The leadflow-enquiry-details value gives the box a unique identifier.
  • The render_details callback supplies the box content.
  • The Post_Type::TYPE value limits the box to LeadFlow records.
  • The normal context places the box in the main editing area.
  • Save includes/class-admin.php.
  • Confirm add_details_box() sits above the final closing brace of the Admin class.

Seeing a misplaced method?

Make sure add_details_box() is inside the class. Its closing brace should appear before the single final brace for Admin.

Need help checking the braces? Help me verify the structure of my LeadFlow Admin class.

The renderer retrieves each metadata value at the moment the client opens a lead. Escaping at output time keeps stored text from being interpreted as dashboard markup.

  • Place your cursor immediately above the final closing brace in includes/class-admin.php.
  • Render the four stored enquiry values by inserting this method:
	public function render_details( $post ): void {
		$name     = \get_post_meta( $post->ID, '_leadflow_name', true );
		$email    = \get_post_meta( $post->ID, '_leadflow_email', true );
		$service  = \get_post_meta( $post->ID, '_leadflow_service', true );
		$message  = \get_post_meta( $post->ID, '_leadflow_message', true );
		$services = Form::service_labels();
		?>
		<p><strong>Name</strong><br><?php echo \esc_html( $name ); ?></p>
		<p><strong>Email</strong><br><?php echo \esc_html( $email ); ?></p>
		<p><strong>Service</strong><br><?php echo \esc_html( $services[ $service ] ?? $service ); ?></p>
		<p><strong>Project message</strong><br><?php echo \nl2br( \esc_html( $message ) ); ?></p>
		<?php
	}
}

How are the details displayed safely?

  • Each get_post_meta() call retrieves one value from the selected lead.
  • The service identifier passes through Form::service_labels() to produce the client-facing label.
  • The esc_html() calls escape every stored value before output.
  • The message reaches nl2br() only after escaping. This preserves readable line breaks without trusting submitted markup.
  • Save includes/class-admin.php.

Before you open a lead, what information do you expect the Enquiry Details box to show?

  • Return to All Leads in the WordPress dashboard.
  • Select the title of an existing lead.

You'll see an Enquiry Details box containing Name, Email, Service, and Project message. Message line breaks remain visible.

You've turned private records into a workflow a client can actually use. Every stored value is readable without sacrificing safe output.

Missing the enquiry details?

  • Confirm you opened a lead record instead of a regular WordPress post.
  • Check that register_hooks() connects add_meta_boxes to add_details_box().
  • Confirm render_details() is inside the Admin class.

Still missing the box? Help me debug my LeadFlow Enquiry Details meta box.

✔️ Awesome, I've got everything!

Great. Save includes/class-admin.php and leadflow.php. Your lead list and detail screen now provide the complete review workflow.

ⓧ I'd like to double check the full code

Compare your completed includes/class-admin.php with this version.

<?php

namespace LeadFlow;

final class Admin {
	public function register_hooks(): void {
		\add_filter( 'manage_' . Post_Type::TYPE . '_posts_columns', array( $this, 'columns' ) );
		\add_action( 'manage_' . Post_Type::TYPE . '_posts_custom_column', array( $this, 'column_content' ), 10, 2 );
		\add_action( 'add_meta_boxes', array( $this, 'add_details_box' ) );
	}

	public function columns( array $columns ): array {
		$updated = array();

		foreach ( $columns as $key => $label ) {
			if ( 'date' === $key ) {
				$updated['leadflow_service'] = 'Service';
				$updated['leadflow_email']   = 'Email';
			}

			$updated[ $key ] = $label;
		}

		return $updated;
	}

	public function column_content( string $column, int $post_id ): void {
		if ( 'leadflow_service' === $column ) {
			$service  = \get_post_meta( $post_id, '_leadflow_service', true );
			$services = Form::service_labels();
			echo \esc_html( $services[ $service ] ?? $service );
		}

		if ( 'leadflow_email' === $column ) {
			echo \esc_html( \get_post_meta( $post_id, '_leadflow_email', true ) );
		}
	}

	public function add_details_box(): void {
		\add_meta_box(
			'leadflow-enquiry-details',
			'Enquiry Details',
			array( $this, 'render_details' ),
			Post_Type::TYPE,
			'normal',
			'high'
		);
	}

	public function render_details( $post ): void {
		$name     = \get_post_meta( $post->ID, '_leadflow_name', true );
		$email    = \get_post_meta( $post->ID, '_leadflow_email', true );
		$service  = \get_post_meta( $post->ID, '_leadflow_service', true );
		$message  = \get_post_meta( $post->ID, '_leadflow_message', true );
		$services = Form::service_labels();
		?>
		<p><strong>Name</strong><br><?php echo \esc_html( $name ); ?></p>
		<p><strong>Email</strong><br><?php echo \esc_html( $email ); ?></p>
		<p><strong>Service</strong><br><?php echo \esc_html( $services[ $service ] ?? $service ); ?></p>
		<p><strong>Project message</strong><br><?php echo \nl2br( \esc_html( $message ) ); ?></p>
		<?php
	}
}

What should match?

This file contains the three admin hooks. It also contains the list-column callbacks and the complete detail-box renderer.

Compare your completed leadflow.php with this version.

<?php
/**
 * Plugin Name: LeadFlow Client Enquiry Manager
 * Description: Captures service enquiries and stores them as private WordPress records.
 * Version: 1.0.0
 * License: GPL-2.0-or-later
 * Text Domain: leadflow
 */

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

define( 'LEADFLOW_VERSION', '1.0.0' );

require_once __DIR__ . '/includes/class-post-type.php';
require_once __DIR__ . '/includes/class-form.php';
require_once __DIR__ . '/includes/class-admin.php';

$post_type = new LeadFlow\Post_Type();
$form      = new LeadFlow\Form();
$admin     = new LeadFlow\Admin();

$post_type->register_hooks();
$form->register_hooks();
$admin->register_hooks();

What should the bootstrap include?

The bootstrap loads all three class files. It creates all three objects and activates each object's hooks.

Your dashboard now supports the complete client review process. Next up, you'll publish LeadFlow to GitHub and turn it into a downloadable plugin with a browser demo.

Publish Your Plugin and Demo

Visitors can now send enquiries through your local WordPress site. Administrators can review those records safely in the dashboard.

That proof still lives on your Mac. A client needs a simple way to review the finished plugin without accessing your local environment.

GitHub makes the source reviewable. WordPress Playground turns the repository into an isolated browser demo.

In this step, get ready to:
  • Create the portfolio handoff files for LeadFlow.
  • Publish the finished source in a public GitHub repository.
  • Verify the downloadable ZIP and browser demo.
Prepare the portfolio handoff files

The README.md file explains what LeadFlow does. It also gives reviewers clear setup and installation paths.

The blueprint.json file describes the temporary Playground demo. Both files use your GitHub username to locate the public repository.

  • Record your GitHub username here: your GitHub username.
  • Create README.md inside the leadflow-wordpress-plugin folder from earlier.
  • Add the project overview and local development instructions by copying this first section into README.md:
# LeadFlow Client Enquiry Manager

LeadFlow is a portfolio WordPress plugin for service businesses. It renders a quote-request form, validates submissions, stores enquiries as private WordPress records, and gives administrators a practical review screen.

## Live demo

Open the isolated browser demo:

https://playground.wordpress.net/?blueprint-url=https://raw.githubusercontent.com/your-github-username/leadflow-wordpress-plugin/main/blueprint.json

Each visitor receives a separate WordPress Playground instance. Demo changes are not shared with other visitors and should not be treated as production data.

## Local development

Requirements: Docker Desktop, Node.js with npm, Git, and @wordpress/env 11.15.0.

```sh
npm -g i @wordpress/env@11.15.0
wp-env start
```

Open http://localhost:8888 and sign in at http://localhost:8888/wp-admin with username `admin` and password `password`.

Stop the environment with:

```sh
wp-env stop
```

What does this README section cover?

  • The opening describes the business outcome that a client can understand.
  • The live demo section gives reviewers a direct path into an isolated Playground instance.
  • The local development section records the tools and commands needed to run LeadFlow again.
  • Save README.md.
  • Use the Markdown preview button in the VS Code editor toolbar.

You should see a rendered title followed by the live demo and local development sections.

README preview looks incomplete?

  • Check that every heading begins with the same number of hash characters shown in the code.
  • Confirm both command sections have an opening and closing row of three backticks.

Still stuck? Help me find the Markdown formatting problem in my LeadFlow README.

  • Return to the bottom of README.md.
  • Add the architecture and handoff sections by copying this code below the existing content:
## Architecture

- `leadflow.php` loads the plugin components.
- `Post_Type` registers private `lf_lead` records with a WordPress admin interface.
- `Form` renders `[leadflow_form]`, validates public submissions, and persists sanitized metadata.
- `Admin` adds useful list columns and a read-only enquiry detail box.

## Security decisions

- Submitted values are unslashed, sanitized by field type, validated, and escaped on output.
- The form uses a WordPress nonce as an intent check.
- Anonymous nonces are not authentication or complete spam protection.
- Redirects use the WordPress safe-redirect API.
- Lead records are not publicly queryable or exposed through the REST API.

## Installable ZIP

On the GitHub repository page, select Code, then Download ZIP. In WordPress, use Plugins > Add New > Upload Plugin > Install Now > Activate Plugin.

## Shortcode

```text
[leadflow_form]
```

## License

GPL-2.0-or-later

What does this handoff section cover?

  • The architecture list maps each class to one responsibility.
  • The security section records how LeadFlow handles untrusted form input.
  • The final sections explain ZIP installation and identify the plugin shortcode.
  • The license section records the project as GPL-2.0-or-later.
  • Save README.md.
  • Return to the Markdown preview.

You should now see sections for Architecture, Security decisions, Installable ZIP, Shortcode, and License.

Missing a README section?

  • Confirm the new content sits below the closing code fence for the local stop command.
  • Check that Architecture begins with two hash characters.

Need another pair of eyes? Help me compare my LeadFlow README sections with the required structure.

✔️ Awesome, I've got everything!

Your README now explains the business outcome, demo behavior, local setup, architecture, security choices, installation flow, shortcode, and license.

ⓧ I'd like to double check the full code

  • Compare your current README.md with this complete template.
# LeadFlow Client Enquiry Manager

LeadFlow is a portfolio WordPress plugin for service businesses. It renders a quote-request form, validates submissions, stores enquiries as private WordPress records, and gives administrators a practical review screen.

## Live demo

Open the isolated browser demo:

https://playground.wordpress.net/?blueprint-url=https://raw.githubusercontent.com/your-github-username/leadflow-wordpress-plugin/main/blueprint.json

Each visitor receives a separate WordPress Playground instance. Demo changes are not shared with other visitors and should not be treated as production data.

## Local development

Requirements: Docker Desktop, Node.js with npm, Git, and @wordpress/env 11.15.0.

```sh
npm -g i @wordpress/env@11.15.0
wp-env start
```

Open http://localhost:8888 and sign in at http://localhost:8888/wp-admin with username `admin` and password `password`.

Stop the environment with:

```sh
wp-env stop
```

## Architecture

- `leadflow.php` loads the plugin components.
- `Post_Type` registers private `lf_lead` records with a WordPress admin interface.
- `Form` renders `[leadflow_form]`, validates public submissions, and persists sanitized metadata.
- `Admin` adds useful list columns and a read-only enquiry detail box.

## Security decisions

- Submitted values are unslashed, sanitized by field type, validated, and escaped on output.
- The form uses a WordPress nonce as an intent check.
- Anonymous nonces are not authentication or complete spam protection.
- Redirects use the WordPress safe-redirect API.
- Lead records are not publicly queryable or exposed through the REST API.

## Installable ZIP

On the GitHub repository page, select Code, then Download ZIP. In WordPress, use Plugins > Add New > Upload Plugin > Install Now > Activate Plugin.

## Shortcode

```text
[leadflow_form]
```

## License

GPL-2.0-or-later

A Playground blueprint combines plugin installation with repeatable demo data. Reviewers start with the same quote page and sample enquiry every time.

  • Create blueprint.json inside the leadflow-wordpress-plugin folder.
  • Build the complete Playground setup by copying this code into blueprint.json:
{
  "$schema": "https://playground.wordpress.net/blueprint-schema.json",
  "landingPage": "/",
  "login": true,
  "siteOptions": {
    "blogname": "LeadFlow Demo"
  },
  "steps": [
    {
      "step": "installPlugin",
      "pluginData": {
        "resource": "git:directory",
        "url": "https://github.com/your-github-username/leadflow-wordpress-plugin",
        "ref": "HEAD",
        "refType": "refname"
      },
      "options": {
        "activate": true
      }
    },
    {
      "step": "runPHP",
      "code": "<?php\nrequire '/wordpress/wp-load.php';\n\n$page_id = wp_insert_post( array(\n\t'post_title' => 'Request a Quote',\n\t'post_name' => 'request-a-quote',\n\t'post_content' => '[leadflow_form]',\n\t'post_status' => 'publish',\n\t'post_type' => 'page',\n), true );\n\nif ( ! is_wp_error( $page_id ) ) {\n\tupdate_option( 'show_on_front', 'page' );\n\tupdate_option( 'page_on_front', $page_id );\n}\n\nwp_insert_post( array(\n\t'post_title' => 'Jamie Chen - Website refresh',\n\t'post_status' => 'private',\n\t'post_type' => 'lf_lead',\n\t'meta_input' => array(\n\t\t'_leadflow_name' => 'Jamie Chen',\n\t\t'_leadflow_email' => 'jamie@example.com',\n\t\t'_leadflow_service' => 'website-refresh',\n\t\t'_leadflow_message' => 'We need a faster, mobile-friendly service website.',\n\t),\n), true );"
    }
  ]
}

What does this blueprint do?

  • The blueprint opens the demo on its front page with automatic dashboard access enabled.
  • The installPlugin step downloads the repository at HEAD and activates LeadFlow.
  • The runPHP step creates the Request a Quote page and sets it as the front page.
  • The same setup step inserts the private Jamie Chen enquiry for dashboard review.
  • Save blueprint.json.
  • Check the editor for red JSON syntax underlines.

You should see a structured JSON document with no syntax warning under the final closing brace.

Seeing a JSON warning?

  • Check the comma after the first object in the steps array.
  • Confirm the embedded PHP remains inside one quoted code value.
  • Check that the file ends with one closing square bracket followed by one closing brace.

Need help locating the mismatch? Help me debug the JSON structure in my LeadFlow Playground blueprint.

✔️ Awesome, I've got everything!

Your blueprint now installs LeadFlow, creates its front page, and seeds a private sample enquiry.

ⓧ I'd like to double check the full code

  • Compare your current blueprint.json with this complete template.
{
  "$schema": "https://playground.wordpress.net/blueprint-schema.json",
  "landingPage": "/",
  "login": true,
  "siteOptions": {
    "blogname": "LeadFlow Demo"
  },
  "steps": [
    {
      "step": "installPlugin",
      "pluginData": {
        "resource": "git:directory",
        "url": "https://github.com/your-github-username/leadflow-wordpress-plugin",
        "ref": "HEAD",
        "refType": "refname"
      },
      "options": {
        "activate": true
      }
    },
    {
      "step": "runPHP",
      "code": "<?php\nrequire '/wordpress/wp-load.php';\n\n$page_id = wp_insert_post( array(\n\t'post_title' => 'Request a Quote',\n\t'post_name' => 'request-a-quote',\n\t'post_content' => '[leadflow_form]',\n\t'post_status' => 'publish',\n\t'post_type' => 'page',\n), true );\n\nif ( ! is_wp_error( $page_id ) ) {\n\tupdate_option( 'show_on_front', 'page' );\n\tupdate_option( 'page_on_front', $page_id );\n}\n\nwp_insert_post( array(\n\t'post_title' => 'Jamie Chen - Website refresh',\n\t'post_status' => 'private',\n\t'post_type' => 'lf_lead',\n\t'meta_input' => array(\n\t\t'_leadflow_name' => 'Jamie Chen',\n\t\t'_leadflow_email' => 'jamie@example.com',\n\t\t'_leadflow_service' => 'website-refresh',\n\t\t'_leadflow_message' => 'We need a faster, mobile-friendly service website.',\n\t),\n), true );"
    }
  ]
}
  • Select the project-wide search icon in the VS Code sidebar.
  • Search for your-github-username.
  • Enter your GitHub username in the replacement field.
  • Apply the replacement to every match.
  • Save README.md.
  • Save blueprint.json.
  • Confirm the project-wide search returns no remaining matches.

Both repository URLs should now contain your real GitHub username. Your handoff files are ready to publish.

Publish the public GitHub repository

A public repository gives reviewers the full source history and a downloadable archive. Git records your finished local files before GitHub hosts them.

  • Return to the GitHub account you verified earlier.
  • Create a new repository from your GitHub account.
  • Enter leadflow-wordpress-plugin as the repository name.
  • Set the repository visibility to Public.
  • Leave every repository initialization option clear.
  • Select Create repository.

The empty repository is ready to receive the files already stored on your Mac.

  • Return to the VS Code terminal panel from earlier.
  • Create the first local repository snapshot by running these commands:
git init -b main
git add .
git commit -m "Build LeadFlow WordPress plugin"

What do these Git commands do?

  • The first command initializes the repository on the main branch.
  • The second command stages every LeadFlow project file for the snapshot.
  • The third command records the staged files with a descriptive commit message.

You should see a commit summary that lists the new project files. That output confirms the finished source is recorded locally.

Commit did not complete?

  • Confirm the terminal is using the leadflow-wordpress-plugin folder from earlier.
  • Check that the Git name and email configured earlier still belong to your verified GitHub identity.
  • Review the terminal output for a file that Git could not stage.

Still blocked? Help me troubleshoot why my first LeadFlow Git commit did not complete.

  • Use your GitHub username in place of your-github-username when you run these commands:
git remote add origin https://github.com/your-github-username/leadflow-wordpress-plugin.git
git remote -v

What does the remote check prove?

  • The first command names your GitHub repository as the origin remote.
  • The second command prints the fetch and push destinations saved under that name.

You should see origin listed twice with your personalized GitHub repository URL.

Remote URL looks wrong?

  • Check that the URL contains your actual GitHub username.
  • Confirm the URL ends with leadflow-wordpress-plugin.git.
  • Confirm the repository name on GitHub matches leadflow-wordpress-plugin exactly.

Need help checking the connection? Help me diagnose why my LeadFlow Git remote points to the wrong GitHub repository.

The push may open a browser authentication flow. Your repository receives the committed files without storing your GitHub password.

  • Publish the main branch by running:
git push -u origin main

What does this push do?

The command uploads your local main branch to origin. The tracking link lets later pushes use the saved destination.

Complete the browser authentication flow if VS Code requests access. The terminal should finish with a summary showing the new remote branch.

Push did not reach GitHub?

  • Complete any GitHub authentication page that VS Code opened in your browser.
  • Confirm your GitHub account has permission to write to the repository.
  • Check the saved destination with the remote verification command from the previous chunk.

Still seeing a push problem? Help me troubleshoot why my LeadFlow main branch will not push to GitHub.

  • Return to the leadflow-wordpress-plugin repository page on GitHub.
  • Refresh the repository page.
  • Confirm the selected branch is main.
  • Confirm the file list includes leadflow.php.
  • Confirm the file list includes README.md.
  • Confirm the file list includes blueprint.json.
  • Confirm the file list includes .wp-env.json.

Your plugin now has a public review path. A client can inspect the architecture without needing your local machine.

Validate both distribution paths

The repository supports two handoff experiences. The ZIP gives a client an installable source archive.

The Playground link gives a reviewer a temporary WordPress site with LeadFlow already activated. The seeded enquiry makes the admin workflow visible immediately.

  • Select Code on the GitHub repository page.
  • Select Download ZIP.
  • Confirm your browser downloads the repository source archive.
  • Read the Installable ZIP section in the repository README.
  • Confirm the WordPress installation path lists Plugins, Add New, Upload Plugin, Install Now, and Activate Plugin in that order.
  • Prepare the Playground demo address by copying this URL:
https://playground.wordpress.net/?blueprint-url=https://raw.githubusercontent.com/your-github-username/leadflow-wordpress-plugin/main/blueprint.json

How does the demo URL work?

The outer address launches WordPress Playground. The blueprint-url value points Playground to the raw blueprint on your main branch.

Each reviewer receives an isolated WordPress instance. Demo changes remain temporary and separate from other visitors.

Before you load the link, what should appear if the repository and blueprint are connected correctly?

  • Replace your-github-username with your GitHub username.
  • Open a private browser window.
  • Paste the personalized URL into the address bar.
  • Press Enter.

You should see the LeadFlow Demo site open on the Request a Quote page. The page should contain the styled Request a project quote form.

  • Enter Alex Morgan in the Name field.
  • Enter alex@example.com in the Email field.
  • Select Website refresh in the Service field.
  • Enter Please refresh our service website. in the Project message field.
  • Select Send enquiry.

You should see Thanks. Your enquiry has been recorded. above the form.

  • Use the Playground admin bar to return to the WordPress dashboard.
  • Select Leads from the dashboard menu.
  • Open Jamie Chen - Website refresh.
  • Confirm the Enquiry Details box shows jamie@example.com.
  • Confirm the project message reads We need a faster, mobile-friendly service website..

That completes the full handoff loop. Reviewers can inspect the source, download the plugin, submit an enquiry, and examine the resulting admin workflow.

Secret mission

Add a Honeypot Spam Guard

Add an off-screen Website field to catch simple form-filling bots. Keep the normal visitor flow unchanged while silently discarding trapped submissions.

Clean Up Your Resources

Clean Up Your Resources

Your LeadFlow project stays on its planned $0 path, so there are no ongoing cloud charges. Choose whether to keep the resources running, pause the local environment, or delete everything.

Resources you used:

  • A running local wp-env environment for WordPress 7.1, including Docker containers, volumes, private lf_lead records, and the Request a Quote page.
  • The local leadflow-wordpress-plugin folder containing the plugin source, README.md, blueprint.json, and .wp-env.json.
  • A public GitHub repository containing the source, ZIP download path, and browser demo Blueprint.
  • A WordPress Playground demo generated from blueprint.json.
  • A downloaded LeadFlow source ZIP in your browser's download location.

What Happens to the Browser Demo?

WordPress Playground gives each visitor a separate browser instance. The persistent demo asset you control is blueprint.json inside your repository.

Keeping the repository preserves the demo link. Deleting the repository prevents new visitors from loading its Blueprint.

Keep everything running

No action needed. Choose this if you are still developing LeadFlow or sharing it with potential clients.

  • Your local WordPress dashboard remains available at http://localhost:8888/wp-admin.
  • Your private lead records remain stored in the local Docker volumes.
  • Your public repository continues providing the source, ZIP download path, and Playground demo.
  • Your local leadflow-wordpress-plugin folder remains available for further development.

Pause - I'll come back to this later

Stop the local environment to release its ports and reduce local resource use. Your project files and public repository remain available.

  • Stop the local environment from the leadflow-wordpress-plugin folder by running this command:
wp-env stop

What Stopping Preserves

The wp-env stop command releases the local ports while keeping the environment available for a later restart. Your saved LeadFlow records remain available.

  • Restart the local environment when you return by running this command:
wp-env start

What Restarting Restores

The wp-env start command starts the local environment again. You can return to the dashboard at http://localhost:8888/wp-admin.

Delete - I don't want to use this again

Deleting the environment removes its saved leads. Repository deletion removes the portfolio source that powers your ZIP and browser demo, so continue only if you no longer need those copies.

  • Destroy the local environment from the leadflow-wordpress-plugin folder by running this command:
wp-env destroy

What Gets Removed

The wp-env destroy command deletes the associated containers, volumes, images, and local generated files. Your source folder and GitHub repository remain until you remove them separately.

  • Locate the leadflow-wordpress-plugin folder in Finder.
  • Move the leadflow-wordpress-plugin folder to the Trash.
  • Open the Downloads folder in Finder.
  • Move the downloaded LeadFlow source ZIP to the Trash.

Your local runtime and source copies are now removed. The public repository remains available until you delete it separately.

  • Open the public leadflow-wordpress-plugin repository on GitHub.
  • Open the repository settings page.
  • Scroll to the repository deletion area.
  • Use the repository deletion control.
  • Complete the confirmation prompt with the repository name.

You should no longer be able to open the repository URL. The Playground demo link also stops loading the Blueprint from GitHub.

Nice Work!

Nice Work!

You did it! LeadFlow is now a security-conscious WordPress plugin that turns public quote requests into private dashboard records.

You've learned how to:

  • Built an object-oriented plugin architecture around a clear bootstrap. Used action hooks to connect registration with request handling. Used filter hooks to customize the lead table.
  • Created a shortcode-driven enquiry form for visitors. Checked each request with a nonce before processing it. Sanitized each field before validation. Stored accepted submissions as private lf_lead records. Escaped each value before displaying it to administrators.
  • Published the finished plugin in a public GitHub repository. Added a client-installable ZIP path for handoff. Created a one-click WordPress Playground demo that seeds the Jamie Chen enquiry for reviewers.
  • Completed the optional Secret Mission by adding an off-screen honeypot field. Filled traps now receive a normal success response without creating another lead.

Ready to quiz yourself?