WordPress Plugin with REST API
Build a WordPress plugin with custom post types, meta fields, and REST API endpoints.
Introduction
30 Second Summary
Every developer needs a way to showcase their projects. WordPress powers over 40% of the web, and building plugins for it means you can extend the world's most popular CMS to do exactly what you need.
In this project, you will build a "Developer Portfolio" plugin that registers a Custom Post Type with custom meta fields, exposes everything through a REST API, and secures write operations with Application Passwords.
What You'll Build
You will create portfolio projects in the WordPress admin, then fetch and manage them from any HTTP client using your own custom API endpoints protected by authentication.
By the end of this project, you'll have:
- A Portfolio Projects section in the WordPress admin where you can create projects with custom fields, then see them returned as JSON by hitting a REST endpoint in your browser.
- A custom REST controller at your own namespaced endpoint that returns a clean, shaped response with full create, read, update, and delete support.
- Authenticated CRUD operations where unauthenticated requests are rejected with a 403, and requests with an Application Password succeed instantly.
- Secret Mission: add custom action and filter hooks to your plugin so other developers can extend the portfolio response data.
Are there any prerequisites?
You need Node.js and VS Code already installed. This guide walks you through installing Docker Desktop and wp-env from scratch, so no prior WordPress development environment is required.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
This project takes you through the architecture of a real WordPress plugin, from registering a Custom Post Type to building a full REST API controller with authentication.
Set Up Docker Desktop and wp-env
WordPress plugin development requires a running WordPress instance to test against. Instead of installing PHP, MySQL, and Apache manually, you will use wp-env, the official WordPress development tool that spins up a complete environment inside Docker containers.
In this step, you will install Docker Desktop, install wp-env, scaffold the plugin directory, and confirm WordPress is running with your plugin activated.
In this step, get ready to:
- Install Docker Desktop and confirm it is running.
- Install wp-env and scaffold the plugin directory structure.
- Start WordPress and verify the plugin is active.
Install Docker Desktop
wp-env is powered by Docker. It needs Docker Desktop running in the background to create the WordPress containers.
- Check if Docker is already installed by running this command in your terminal:
docker --version
✔️ I see a version number
Docker is installed. Make sure Docker Desktop is running by checking your system tray (Windows) or menu bar (macOS) for the Docker whale icon.
- If the icon is not there, open Docker Desktop by pressing Cmd+Space (macOS) or the Windows key (Windows), typing Docker, and pressing Enter.
ⓧ Command not found
Docker is not installed yet. Follow the instructions for your operating system below.
macOS
- Open the Docker Desktop for Mac install page.
- Download the installer for your chip (Apple Silicon or Intel).
- Double-click Docker.dmg and drag the Docker icon to the Applications folder.
- Open Docker.app from Applications.
- Accept the Docker Subscription Service Agreement when prompted.
- Wait for Docker Desktop to finish starting. You will see the whale icon in your menu bar when it is ready.
Windows
- Open the Docker Desktop for Windows install page.
- Download and run the installer.
- During installation, ensure the Use WSL 2 instead of Hyper-V option is selected (recommended for performance).
- Follow the installer prompts and restart your computer if asked.
- After restart, Docker Desktop launches automatically. Accept the service agreement when prompted.
- Wait for Docker Desktop to finish starting. You will see the whale icon in your system tray when it is ready.
- Confirm Docker is running by running this command again:
docker --version
You should see a version number printed, confirming Docker is ready.
Docker command still not found?
On Windows, you may need to close and reopen your terminal after installing Docker Desktop so the PATH updates take effect. On macOS, Docker Desktop creates symlinks automatically when it first launches.
help me troubleshoot Docker Desktop not being recognized in my terminal
Install wp-env and scaffold the plugin directory
wp-env is a Node.js package maintained by the WordPress team. It reads a configuration file from your plugin directory, then launches a WordPress site inside Docker with your plugin already activated.
- Install wp-env globally by running this command:
npm -g install @wordpress/env
- Verify the installation by running:
wp-env --version
✔️ I see a version number
wp-env is installed and ready.
ⓧ Command not found
The global npm bin directory may not be in your PATH.
- Run npm config get prefix to see where global packages are installed.
- Add that path (with /bin appended on macOS/Linux) to your system PATH.
- Close and reopen your terminal, then try wp-env --version again.
help me add npm global packages to my PATH
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your plugin folder and move into it by running these commands:
mkdir developer-portfolio
cd developer-portfolio
- Create the includes/ subfolder by running:
mkdir includes
- Confirm the directory structure by running:
ls
You should see the includes folder listed in the output.
- Open the developer-portfolio folder in VS Code by running:
code .
VS Code opens with the developer-portfolio folder as the workspace root.
code command not recognized?
In VS Code, press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows) to open the Command Palette. Type Shell Command: Install 'code' command in PATH and select it. Then close and reopen your terminal and try again.
help me set up the code command in my terminal
- In VS Code, create a new file called .wp-env.json and paste this content:
{
"core": null,
"plugins": ["."]
}
What does this file do?
- "core": null tells wp-env to download the latest version of WordPress automatically.
- "plugins": ["."] tells wp-env to mount the current directory as a plugin and activate it.
- Save .wp-env.json.
- Create another new file called developer-portfolio.php and paste this content:
<?php
/**
* Plugin Name: Developer Portfolio
* Description: A custom portfolio plugin with REST API support.
* Version: 1.0.0
* Author: Developer
* Text Domain: developer-portfolio
*/
What does this file do?
This is the plugin header. WordPress scans all .php files in the plugins directory for this specially formatted comment block. The Plugin Name line is the only required field. Everything else (description, version, author) is metadata WordPress displays on the Plugins screen.
- Save developer-portfolio.php.
✔️ Awesome, I've got everything!
Great. Double check you have saved both files before continuing.
ⓧ I'd like to double check the full code
Your .wp-env.json should look like this:
{
"core": null,
"plugins": ["."]
}
Your developer-portfolio.php should look like this:
<?php
/**
* Plugin Name: Developer Portfolio
* Description: A custom portfolio plugin with REST API support.
* Version: 1.0.0
* Author: Developer
* Text Domain: developer-portfolio
*/
Start WordPress and verify the plugin is active
With Docker running and the plugin files in place, wp-env can now launch a complete WordPress environment.
- In your terminal (make sure you are inside the developer-portfolio folder), start the environment by running:
wp-env start
What does this command do?
wp-env downloads WordPress Docker images, creates containers for WordPress and MySQL, mounts your plugin directory into the container, and activates the plugin automatically. The first run takes a few minutes while it downloads images.
When it finishes, you will see a message confirming WordPress is running at http://localhost:8888.
Seeing a port conflict or Docker error?
If port 8888 is already in use, stop the other process using that port before retrying. If Docker errors appear, make sure Docker Desktop is fully started (the whale icon is steady, not animating) before running wp-env start again.
help me fix wp-env start failing with Docker or port errors
Before you open the admin, what do you think you will see on the Plugins page? Will the plugin need manual activation, or is it already active?
- Open your browser and navigate to http://localhost:8888/wp-admin.
- Log in with username admin and password password.
- Click Plugins in the left sidebar.
You should see Developer Portfolio listed as an active plugin with the description "A custom portfolio plugin with REST API support."
Plugin not showing as active?
Make sure developer-portfolio.php is in the root of the folder where you ran wp-env start (not inside a subfolder). The .wp-env.json file must also be in the same root directory. If you moved files after starting, stop the environment with wp-env stop and run wp-env start again.
help me fix my Developer Portfolio plugin not appearing in wp-admin
Your local WordPress environment is running with your plugin activated. Next up, you will register a Custom Post Type and see WordPress instantly generate a REST API for it.
Register the Custom Post Type and Verify the REST Endpoint
In WordPress, a Custom Post Type is the equivalent of defining an Eloquent model plus its migration in Laravel. It tells WordPress that a new kind of content exists, what it supports, and how it behaves.
The powerful part: setting a single flag (show_in_rest => true) gives you a full REST API for that content type without writing a single route definition. In this step, you will register a Portfolio Project post type and confirm WordPress exposes it as a JSON endpoint.
In this step, get ready to:
- Register a Custom Post Type with full REST API support.
- Add activation and deactivation hooks for rewrite rule management.
- Create test content and verify the JSON endpoint returns it.
Add the security guard and register the Custom Post Type
WordPress plugins that are accessed directly (not through WordPress) can be a security risk. The ABSPATH guard prevents this by checking whether WordPress loaded the file.
- In VS Code, open developer-portfolio.php.
- Below the closing */ of the plugin header comment, add the ABSPATH guard, two constants, and the first half of the registration function by copying this code:
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// Plugin version and file path constants
define( 'DEV_PORTFOLIO_VERSION', '1.0.0' );
define( 'DEV_PORTFOLIO_PATH', plugin_dir_path( __FILE__ ) );
/**
* Register the Portfolio Project custom post type.
*/
function devportfolio_register_post_type() {
// Labels that appear throughout the WordPress admin
$labels = array(
'name' => __( 'Portfolio Projects', 'developer-portfolio' ),
'singular_name' => __( 'Portfolio Project', 'developer-portfolio' ),
'menu_name' => __( 'Portfolio', 'developer-portfolio' ),
'add_new' => __( 'Add New', 'developer-portfolio' ),
'add_new_item' => __( 'Add New Project', 'developer-portfolio' ),
'edit_item' => __( 'Edit Project', 'developer-portfolio' ),
'new_item' => __( 'New Project', 'developer-portfolio' ),
'view_item' => __( 'View Project', 'developer-portfolio' ),
'all_items' => __( 'All Projects', 'developer-portfolio' ),
'search_items' => __( 'Search Projects', 'developer-portfolio' ),
'not_found' => __( 'No projects found.', 'developer-portfolio' ),
'not_found_in_trash' => __( 'No projects found in Trash.', 'developer-portfolio' ),
);
What does this code do?
- The ABSPATH check stops anyone from loading this file directly in a browser. If WordPress did not load it, the script exits immediately.
- DEV_PORTFOLIO_VERSION and DEV_PORTFOLIO_PATH are constants you can reuse anywhere in the plugin for cache busting and file includes.
- The $labels array controls every piece of text WordPress displays in the admin for this content type. The __() function wraps each string for translation support.
- Directly below the $labels array (still inside the function), add the configuration arguments and the register call:
// Configuration arguments for the post type
$args = array(
'labels' => $labels,
'public' => true,
'publicly_queryable' => true,
'show_ui' => true,
'show_in_menu' => true,
'query_var' => true,
'rewrite' => array( 'slug' => 'portfolio' ),
'capability_type' => 'post',
'has_archive' => true,
'hierarchical' => false,
'menu_position' => 20,
'menu_icon' => 'dashicons-portfolio',
'show_in_rest' => true,
'rest_base' => 'portfolio-projects',
'supports' => array( 'title', 'editor', 'excerpt', 'thumbnail' ),
);
register_post_type( 'portfolio_project', $args );
}
add_action( 'init', 'devportfolio_register_post_type' );
What do these arguments control?
- show_in_rest => true is the single flag that exposes this post type through the WordPress REST API. Without it, no JSON endpoint exists.
- rest_base => 'portfolio-projects' sets the URL slug for the endpoint. Your projects will live at /wp-json/wp/v2/portfolio-projects.
- supports declares which editing features WordPress enables for this type (title, block editor, excerpt, featured image).
- add_action( 'init', ... ) hooks your function to the init action. WordPress calls it on every request, registering your post type before anything else runs.
Why hook to init instead of calling register_post_type directly?
WordPress uses an event-driven architecture called hooks. The init action fires after WordPress core is loaded but before any output is sent. Hooking here guarantees your post type exists before queries, menus, or REST routes are built.
- Save developer-portfolio.php.
- Switch to your browser and refresh the WordPress admin at http://localhost:8888/wp-admin.
- Look at the left sidebar. You should see a new Portfolio menu item with a briefcase icon.
Don't see the Portfolio menu?
- Check for a white screen or PHP error at the top of the page. This usually means a syntax error in your file. Make sure the function's closing brace } appears after the register_post_type() call.
- Confirm the add_action line sits OUTSIDE the function (after the closing brace), not inside it.
- Verify the plugin is still active under Plugins in the admin sidebar. A fatal error can silently deactivate it.
Help me debug why my Portfolio menu isn't showing up in the WordPress admin.
Add activation and deactivation hooks
WordPress needs to know about your post type's URL structure so it can route requests correctly. Flushing rewrite rules on plugin activation tells WordPress to rebuild its internal URL map.
- In developer-portfolio.php, add the activation and deactivation hooks below the add_action( 'init', 'devportfolio_register_post_type' ); line:
/**
* Flush rewrite rules on activation.
*/
function devportfolio_activate() {
devportfolio_register_post_type();
flush_rewrite_rules();
}
register_activation_hook( __FILE__, 'devportfolio_activate' );
/**
* Flush rewrite rules on deactivation.
*/
function devportfolio_deactivate() {
unregister_post_type( 'portfolio_project' );
flush_rewrite_rules();
}
register_deactivation_hook( __FILE__, 'devportfolio_deactivate' );
What do these hooks do?
- register_activation_hook fires once when the plugin is activated. It registers the CPT first (so WordPress knows about its URLs), then flushes rewrite rules to make those URLs resolve.
- register_deactivation_hook fires when the plugin is deactivated. It unregisters the post type and cleans up the URL rules so WordPress does not try to route to content that no longer exists.
- Save developer-portfolio.php.
- In the WordPress admin, navigate to Plugins in the left sidebar.
- Click Deactivate under Developer Portfolio.
- Click Activate to reactivate it. This triggers your new activation hook and flushes the rewrite rules.
Plugin won't reactivate?
If clicking Activate shows an error, your PHP file has a syntax issue. Open developer-portfolio.php and check that every opening brace has a matching close, and that the register_activation_hook and register_deactivation_hook calls are outside any function body.
Help me fix a syntax error preventing my WordPress plugin from activating.
Create a test project and query the REST API
Your Custom Post Type is registered and active. Time to create some content and prove the REST API serves it as JSON.
- In the WordPress admin sidebar, click Portfolio.
- Click Add New Project at the top of the page.
- Enter My First Portfolio Project as the title.
- In the block editor content area, type A sample project to test the REST API endpoint..
- Click Publish in the top right corner.
- A confirmation panel appears. Click Publish again to confirm.
Your test project is now saved in the WordPress database. Before you check the REST endpoint, what do you think you will see when you navigate to the JSON URL? A single object, an array, or an error?
- Open a new browser tab.
- Navigate to http://localhost:8888/wp-json/wp/v2/portfolio-projects.
You see a JSON array containing your test project. The response includes fields like title, content, and slug. WordPress generated all of this from the single register_post_type call with show_in_rest => true. No route definitions, no controller, no serializer.
Seeing a 404 or empty array?
- If you see a 404, the rewrite rules were not flushed. Go back to Plugins, deactivate, then reactivate the Developer Portfolio plugin.
- If you see an empty array [], make sure you published the test project (not just saved as draft). Check its status under Portfolio > All Projects.
- Double check the URL. It must be exactly /wp-json/wp/v2/portfolio-projects (matching your rest_base value).
Help me troubleshoot why my Custom Post Type REST endpoint returns a 404 or empty array.
✔️ Awesome, I've got everything!
Great. Double check you have saved your file before moving on.
ⓧ I'd like to double check the full code
Your developer-portfolio.php should contain these sections in order. Scroll up and confirm each block is present:
- The plugin header comment (already existed).
- The ABSPATH guard and two define() constants.
- The devportfolio_register_post_type() function containing the $labels array, the $args array, and the register_post_type() call.
- The add_action( 'init', 'devportfolio_register_post_type' ); line outside the function.
- The devportfolio_activate() function and its register_activation_hook call.
- The devportfolio_deactivate() function and its register_deactivation_hook call.
Your Custom Post Type is live and serving JSON. Next up, you will register custom meta fields to attach project URLs and a tech stack to each portfolio entry.
Register Meta Fields and Hit the Missing-Meta Wall
In the previous step you registered a Custom Post Type and immediately got a working REST endpoint. Now you need to add custom data fields to each project: a live URL, a GitHub URL, and a tech stack description.
In Laravel, adding new fields means adding columns to a migration and they appear in your API Resource automatically. WordPress stores custom data in the wp_postmeta table, and each field must be explicitly registered and exposed. This step shows what happens when you follow the official registration steps but miss one critical flag.
In this step, get ready to:
- Register three custom meta fields for the portfolio post type.
- Register a Tech Category taxonomy attached to portfolio projects.
- Save meta values and discover the missing-meta wall in the REST response.
Register custom meta fields
WordPress uses register_post_meta to declare which meta keys belong to a post type, how to sanitize them, and whether they should appear in the REST API.
- In VS Code, open developer-portfolio.php.
- Find the closing brace of the devportfolio_register_post_type function and its add_action line.
- Below that add_action line, add the following meta registration function:
/**
* Register custom meta fields for the portfolio post type.
*/
function devportfolio_register_meta() {
register_post_meta( 'portfolio_project', 'devportfolio_project_url', array(
'show_in_rest' => true,
'single' => true,
'type' => 'string',
'sanitize_callback' => 'esc_url_raw',
) );
register_post_meta( 'portfolio_project', 'devportfolio_github_url', array(
'show_in_rest' => true,
'single' => true,
'type' => 'string',
'sanitize_callback' => 'esc_url_raw',
) );
register_post_meta( 'portfolio_project', 'devportfolio_tech_stack', array(
'show_in_rest' => true,
'single' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
) );
}
add_action( 'init', 'devportfolio_register_meta' );
What does this code do?
Each register_post_meta call declares a meta key that belongs to the portfolio_project post type.
- show_in_rest => true tells WordPress this field should be exposed through the REST API.
- single => true means each project stores one value per key (not an array of values).
- sanitize_callback runs every time a value is saved. URL fields use esc_url_raw to strip dangerous characters. The tech stack uses sanitize_text_field for plain text.
- Save developer-portfolio.php.
Register the Tech Category taxonomy
A taxonomy in WordPress is a classification system. Categories and tags are built-in taxonomies. You will create a custom one called Tech Category so you can tag projects with technology labels like "React" or "Docker".
- In developer-portfolio.php, place your cursor below the add_action( 'init', 'devportfolio_register_post_type' ); line.
- Add the taxonomy registration function:
/**
* Register the Tech Category taxonomy.
*/
function devportfolio_register_taxonomy() {
$labels = array(
'name' => __( 'Tech Categories', 'developer-portfolio' ),
'singular_name' => __( 'Tech Category', 'developer-portfolio' ),
'search_items' => __( 'Search Tech Categories', 'developer-portfolio' ),
'all_items' => __( 'All Tech Categories', 'developer-portfolio' ),
'parent_item' => __( 'Parent Tech Category', 'developer-portfolio' ),
'parent_item_colon' => __( 'Parent Tech Category:', 'developer-portfolio' ),
'edit_item' => __( 'Edit Tech Category', 'developer-portfolio' ),
'update_item' => __( 'Update Tech Category', 'developer-portfolio' ),
'add_new_item' => __( 'Add New Tech Category', 'developer-portfolio' ),
'new_item_name' => __( 'New Tech Category Name', 'developer-portfolio' ),
'menu_name' => __( 'Tech Categories', 'developer-portfolio' ),
);
$args = array(
'labels' => $labels,
'hierarchical' => true,
'public' => true,
'show_ui' => true,
'show_admin_column' => true,
'show_in_rest' => true,
'rest_base' => 'tech-categories',
'rewrite' => array( 'slug' => 'tech-category' ),
);
register_taxonomy( 'tech_category', array( 'portfolio_project' ), $args );
}
add_action( 'init', 'devportfolio_register_taxonomy' );
What does this code do?
register_taxonomy creates a new classification system and attaches it to your portfolio_project post type.
- hierarchical => true makes it behave like categories (parent/child) rather than flat tags.
- show_in_rest => true exposes the taxonomy through the REST API at /wp-json/wp/v2/tech-categories.
- show_admin_column => true adds a Tech Categories column to the portfolio projects list in wp-admin.
You also need to update the activation and deactivation hooks so WordPress registers the taxonomy on activation and cleans it up on deactivation.
- Scroll down to the devportfolio_activate function. Find this line:
function devportfolio_activate() {
devportfolio_register_post_type();
flush_rewrite_rules();
}
- Add devportfolio_register_taxonomy(); between devportfolio_register_post_type(); and flush_rewrite_rules(); so it looks like this:
function devportfolio_activate() {
devportfolio_register_post_type();
devportfolio_register_taxonomy();
flush_rewrite_rules();
}
- Now find the devportfolio_deactivate function. It currently looks like this:
function devportfolio_deactivate() {
unregister_post_type( 'portfolio_project' );
flush_rewrite_rules();
}
- Add unregister_taxonomy( 'tech_category' ); between unregister_post_type and flush_rewrite_rules so it looks like this:
function devportfolio_deactivate() {
unregister_post_type( 'portfolio_project' );
unregister_taxonomy( 'tech_category' );
flush_rewrite_rules();
}
- Save developer-portfolio.php.
- Switch to your browser and navigate to http://localhost:8888/wp-admin. You should see a new Tech Categories submenu under the Portfolio menu in the sidebar.
Don't see Tech Categories in the sidebar?
- Make sure you saved the file. Check for PHP syntax errors by refreshing the wp-admin page. A white screen or error message means a typo in your code.
- Confirm the add_action( 'init', 'devportfolio_register_taxonomy' ); line is outside the function body, not inside another function.
- help me debug my taxonomy registration
Save meta values and query the REST API
Your meta fields are registered and the taxonomy exists. Now add some actual data to your test project and check whether the REST API returns it.
- In wp-admin, click Portfolio in the sidebar.
- Click on your test project to open it in the block editor.
- Click the three-dot options menu in the top-right corner of the editor toolbar.
- Select Preferences.
- In the General tab, find the Advanced section and toggle Custom fields on.
- Click Show & Reload Page. The editor reloads and a Custom Fields panel appears at the bottom.
What is the Custom Fields panel?
This is WordPress's built-in UI for viewing and editing raw post meta. It shows key-value pairs stored in the wp_postmeta table for the current post. You are using it here to manually enter meta values for testing.
- In the Custom Fields panel, click Enter new to add a new field.
- Set the Name to devportfolio_project_url and the Value to https://example.com.
- Click Add Custom Field.
- Add another field with Name devportfolio_github_url and Value https://github.com/example/project.
- Add a third field with Name devportfolio_tech_stack and Value PHP, WordPress, React.
- Click Update at the top of the editor to save the post.
Your meta values are now stored in the database. Before you check the REST API, take a moment to predict what you expect to see.
You registered each field with show_in_rest => true. Do you think those values will appear in the JSON response?
- Open a new browser tab and navigate to:
http://localhost:8888/wp-json/wp/v2/portfolio-projects
- Find the meta field in the JSON response.
The meta field is an empty object: {}. Your custom fields are nowhere to be seen.
This is the missing-meta wall
You did everything the documentation says: you called register_post_meta with show_in_rest => true. The values are saved in the database (you can see them in the Custom Fields panel). Yet the REST API returns meta: {}.
Something else is required for registered meta to actually appear in REST responses. The next step will reveal what it is and fix it.
✔️ Awesome, I've got everything!
Great. Double check you saved developer-portfolio.php with all three sections: the meta registration function, the taxonomy registration function, and the updated activation/deactivation hooks.
ⓧ I'd like to double check the full code
Here is the complete developer-portfolio.php at the end of this step. Scroll through and confirm each section is present in order.
Your file has these sections from top to bottom:
- Plugin header and ABSPATH guard.
- Constants (DEV_PORTFOLIO_VERSION and DEV_PORTFOLIO_PATH).
- devportfolio_register_post_type() function and its add_action.
- devportfolio_register_taxonomy() function and its add_action.
- devportfolio_register_meta() function and its add_action.
- devportfolio_activate() with register_activation_hook.
- devportfolio_deactivate() with register_deactivation_hook.
You have registered meta fields, built a custom taxonomy, and hit the missing-meta wall. The fields are stored correctly but the REST API refuses to show them. In the next step, you will fix this gap with a single word and then build a custom REST controller that gives you full control over the response shape.
Fix the Meta Gap and Build a Custom REST Controller
In the last step, you registered meta fields with show_in_rest => true and saved values in the database. Yet when you queried the built-in REST endpoint, the meta field came back as an empty object.
The fix is a single flag WordPress requires but buries in its documentation. Once the built-in endpoint works, you will go further and build your own WP_REST_Controller that shapes the response exactly how a frontend consumer expects it.
In this step, get ready to:
- Fix the missing custom-fields support so meta appears in the built-in REST response.
- Build a custom REST controller with a clean, shaped JSON response at your own namespaced endpoint.
- Wire the controller into your plugin and verify it returns project data.
Add custom-fields to the CPT supports array
WordPress requires a Custom Post Type to explicitly declare custom-fields in its supports array before registered meta will appear in REST responses. Without this flag, register_post_meta with show_in_rest is necessary but not sufficient.
- In developer-portfolio.php, scroll down to the devportfolio_register_post_type function.
- Find this line in the $args array:
'supports' => array( 'title', 'editor', 'excerpt', 'thumbnail' ),
- Add 'custom-fields' to the end of that array, so the line now looks like this:
'supports' => array( 'title', 'editor', 'excerpt', 'thumbnail', 'custom-fields' ),
- Save developer-portfolio.php.
Before you check the endpoint, what do you think the meta field will contain now?
- Open a new browser tab and navigate to http://localhost:8888/wp-json/wp/v2/portfolio-projects.
The meta field now shows your registered fields with the values you entered earlier. The single missing custom-fields flag was the entire gap.
Why does WordPress need this extra flag?
In Laravel, defining a field on your model and adding it to your API Resource's toArray() is enough. WordPress splits the concern: register_post_meta tells the meta system about the field, but the CPT's supports array acts as a gate controlling which subsystems are active for that post type.
Without custom-fields in supports, the REST controller for that post type skips meta entirely.
Still seeing an empty meta object?
Make sure you saved the file and that your browser is not showing a cached response. Try a hard refresh or open the URL in an incognito window.
Confirm the spelling is exactly 'custom-fields' with a hyphen, not an underscore.
help me debug my empty meta object in the WordPress REST API
Create the controller class with route registration
The built-in endpoint works, but it wraps meta inside a nested meta object and includes fields you may not need. A custom WP_REST_Controller lets you shape the response exactly how a frontend app expects it.
- Create a new file at includes/class-portfolio-rest-controller.php inside your developer-portfolio folder.
- Add the file header, ABSPATH guard, and class declaration with the constructor by pasting this code:
<?php
/**
* Custom REST API Controller for Portfolio Projects.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class DevPortfolio_REST_Controller extends WP_REST_Controller {
public function __construct() {
$this->namespace = 'developer-portfolio/v1';
$this->rest_base = 'projects';
}
What does this code do?
The class extends WP_REST_Controller, which is the base class WordPress core uses for all its own REST endpoints.
The constructor sets the namespace (developer-portfolio/v1) and the resource base (projects). Together they form the URL: /wp-json/developer-portfolio/v1/projects.
- Below the constructor, add the first half of the register_routes method. This registers the collection route (list all projects and create a project):
public function register_routes() {
// Collection route: GET all projects, POST a new project
register_rest_route( $this->namespace, '/' . $this->rest_base, array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_items' ),
'permission_callback' => '__return_true',
'args' => $this->get_collection_params(),
),
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'create_item' ),
'permission_callback' => array( $this, 'create_item_permissions_check' ),
),
'schema' => array( $this, 'get_public_item_schema' ),
) );
What does this code do?
This registers a route at /projects under your namespace. WP_REST_Server::READABLE maps to GET requests. WP_REST_Server::CREATABLE maps to POST.
The permission_callback for GET is __return_true (public read access). The POST route uses a method that checks if the user can publish posts.
- Directly below the first register_rest_route call (still inside the register_routes method), add the single-item routes:
// Single item route: GET, PUT/PATCH, DELETE one project by ID
register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<id>[\d]+)', array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_item' ),
'permission_callback' => '__return_true',
'args' => array(
'id' => array(
'validate_callback' => function( $param ) {
return is_numeric( $param );
},
),
),
),
array(
'methods' => WP_REST_Server::EDITABLE,
'callback' => array( $this, 'update_item' ),
'permission_callback' => array( $this, 'update_item_permissions_check' ),
),
array(
'methods' => WP_REST_Server::DELETABLE,
'callback' => array( $this, 'delete_item' ),
'permission_callback' => array( $this, 'delete_item_permissions_check' ),
),
) );
}
What does this code do?
This registers routes for a single project by ID (the regex (?P<id>[\d]+) captures the numeric ID from the URL).
WP_REST_Server::EDITABLE covers both PUT and PATCH. WP_REST_Server::DELETABLE covers DELETE. Each write route has its own permission callback that checks capabilities.
- Next, add the get_items and get_item methods. These handle GET requests for the collection and a single project:
public function get_items( $request ) {
$args = array(
'post_type' => 'portfolio_project',
'posts_per_page' => $request->get_param( 'per_page' ) ?? 10,
'paged' => $request->get_param( 'page' ) ?? 1,
'post_status' => 'publish',
);
$query = new WP_Query( $args );
$projects = array();
foreach ( $query->posts as $post ) {
$projects[] = $this->prepare_item_for_response( $post, $request )->get_data();
}
$response = new WP_REST_Response( $projects, 200 );
$response->header( 'X-WP-Total', $query->found_posts );
$response->header( 'X-WP-TotalPages', $query->max_num_pages );
return $response;
}
public function get_item( $request ) {
$post = get_post( $request['id'] );
if ( ! $post || 'portfolio_project' !== $post->post_type ) {
return new WP_Error(
'rest_project_not_found',
__( 'Project not found.', 'developer-portfolio' ),
array( 'status' => 404 )
);
}
return $this->prepare_item_for_response( $post, $request );
}
What does this code do?
get_items runs a WP_Query for published portfolio projects, respecting pagination params. It adds standard WordPress pagination headers (X-WP-Total and X-WP-TotalPages).
get_item fetches a single post by ID and returns a 404 error if it does not exist or is the wrong post type.
- Add the prepare_item_for_response method. This is the heart of the controller. It shapes each project into the exact JSON structure your frontend needs:
public function prepare_item_for_response( $post, $request ) {
$tech_categories = wp_get_object_terms( $post->ID, 'tech_category', array( 'fields' => 'names' ) );
$data = array(
'id' => $post->ID,
'title' => $post->post_title,
'content' => apply_filters( 'the_content', $post->post_content ),
'excerpt' => $post->post_excerpt,
'slug' => $post->post_name,
'date' => $post->post_date,
'modified' => $post->post_modified,
'project_url' => get_post_meta( $post->ID, 'devportfolio_project_url', true ),
'github_url' => get_post_meta( $post->ID, 'devportfolio_github_url', true ),
'tech_stack' => get_post_meta( $post->ID, 'devportfolio_tech_stack', true ),
'tech_categories' => is_wp_error( $tech_categories ) ? array() : $tech_categories,
);
return new WP_REST_Response( $data, 200 );
}
What does this code do?
Instead of burying meta inside a nested meta object like the built-in endpoint does, this method places project_url, github_url, and tech_stack directly in the response body.
It also resolves taxonomy terms into a simple array of names. This is what a React or Vue frontend actually wants to consume.
- Now add the create_item method that handles POST requests to create a new project:
public function create_item( $request ) {
$post_data = array(
'post_type' => 'portfolio_project',
'post_title' => sanitize_text_field( $request->get_param( 'title' ) ),
'post_content' => wp_kses_post( $request->get_param( 'content' ) ?? '' ),
'post_excerpt' => sanitize_text_field( $request->get_param( 'excerpt' ) ?? '' ),
'post_status' => 'publish',
);
$post_id = wp_insert_post( $post_data, true );
if ( is_wp_error( $post_id ) ) {
return $post_id;
}
if ( $request->get_param( 'project_url' ) ) {
update_post_meta( $post_id, 'devportfolio_project_url', esc_url_raw( $request->get_param( 'project_url' ) ) );
}
if ( $request->get_param( 'github_url' ) ) {
update_post_meta( $post_id, 'devportfolio_github_url', esc_url_raw( $request->get_param( 'github_url' ) ) );
}
if ( $request->get_param( 'tech_stack' ) ) {
update_post_meta( $post_id, 'devportfolio_tech_stack', sanitize_text_field( $request->get_param( 'tech_stack' ) ) );
}
if ( $request->get_param( 'tech_categories' ) ) {
wp_set_object_terms( $post_id, $request->get_param( 'tech_categories' ), 'tech_category' );
}
$post = get_post( $post_id );
return new WP_REST_Response(
$this->prepare_item_for_response( $post, $request )->get_data(),
201
);
}
What does this code do?
This creates a new portfolio_project post, saves any meta fields from the request body, and returns the created project with a 201 status.
Every input is sanitized: sanitize_text_field for plain text, wp_kses_post for HTML content, and esc_url_raw for URLs.
- Add the update_item method below create_item. This handles PUT/PATCH requests:
public function update_item( $request ) {
$post = get_post( $request['id'] );
if ( ! $post || 'portfolio_project' !== $post->post_type ) {
return new WP_Error(
'rest_project_not_found',
__( 'Project not found.', 'developer-portfolio' ),
array( 'status' => 404 )
);
}
$post_data = array( 'ID' => $post->ID );
if ( $request->get_param( 'title' ) ) {
$post_data['post_title'] = sanitize_text_field( $request->get_param( 'title' ) );
}
if ( $request->get_param( 'content' ) !== null ) {
$post_data['post_content'] = wp_kses_post( $request->get_param( 'content' ) );
}
if ( $request->get_param( 'excerpt' ) !== null ) {
$post_data['post_excerpt'] = sanitize_text_field( $request->get_param( 'excerpt' ) );
}
wp_update_post( $post_data );
- Immediately below the wp_update_post call you just pasted, add the meta update logic and the return statement to complete the method:
if ( $request->get_param( 'project_url' ) !== null ) {
update_post_meta( $post->ID, 'devportfolio_project_url', esc_url_raw( $request->get_param( 'project_url' ) ) );
}
if ( $request->get_param( 'github_url' ) !== null ) {
update_post_meta( $post->ID, 'devportfolio_github_url', esc_url_raw( $request->get_param( 'github_url' ) ) );
}
if ( $request->get_param( 'tech_stack' ) !== null ) {
update_post_meta( $post->ID, 'devportfolio_tech_stack', sanitize_text_field( $request->get_param( 'tech_stack' ) ) );
}
if ( $request->get_param( 'tech_categories' ) !== null ) {
wp_set_object_terms( $post->ID, $request->get_param( 'tech_categories' ), 'tech_category' );
}
$updated_post = get_post( $post->ID );
return $this->prepare_item_for_response( $updated_post, $request );
}
What does update_item do?
It validates the project exists, then only updates the fields the client sends. Unset fields are left untouched. This is the standard PATCH-style partial update pattern.
- Add the delete_item method:
public function delete_item( $request ) {
$post = get_post( $request['id'] );
if ( ! $post || 'portfolio_project' !== $post->post_type ) {
return new WP_Error(
'rest_project_not_found',
__( 'Project not found.', 'developer-portfolio' ),
array( 'status' => 404 )
);
}
$result = wp_delete_post( $post->ID, true );
if ( ! $result ) {
return new WP_Error(
'rest_cannot_delete',
__( 'The project could not be deleted.', 'developer-portfolio' ),
array( 'status' => 500 )
);
}
return new WP_REST_Response( null, 204 );
}
What does this code do?
The second argument true in wp_delete_post means force-delete (skip the trash). On success it returns a 204 No Content response, which is the standard HTTP status for a successful delete.
- Add the three permission check methods and get_collection_params, then close the class with a final }:
public function create_item_permissions_check( $request ) {
if ( ! current_user_can( 'publish_posts' ) ) {
return new WP_Error(
'rest_forbidden',
__( 'You do not have permission to create projects.', 'developer-portfolio' ),
array( 'status' => 403 )
);
}
return true;
}
public function update_item_permissions_check( $request ) {
$post = get_post( $request['id'] );
if ( ! $post ) {
return new WP_Error(
'rest_project_not_found',
__( 'Project not found.', 'developer-portfolio' ),
array( 'status' => 404 )
);
}
if ( ! current_user_can( 'edit_post', $post->ID ) ) {
return new WP_Error(
'rest_forbidden',
__( 'You do not have permission to edit this project.', 'developer-portfolio' ),
array( 'status' => 403 )
);
}
return true;
}
public function delete_item_permissions_check( $request ) {
$post = get_post( $request['id'] );
if ( ! $post ) {
return new WP_Error(
'rest_project_not_found',
__( 'Project not found.', 'developer-portfolio' ),
array( 'status' => 404 )
);
}
if ( ! current_user_can( 'delete_post', $post->ID ) ) {
return new WP_Error(
'rest_forbidden',
__( 'You do not have permission to delete this project.', 'developer-portfolio' ),
array( 'status' => 403 )
);
}
return true;
}
public function get_collection_params() {
return array(
'page' => array(
'description' => __( 'Current page of the collection.', 'developer-portfolio' ),
'type' => 'integer',
'default' => 1,
'minimum' => 1,
),
'per_page' => array(
'description' => __( 'Maximum number of items per page.', 'developer-portfolio' ),
'type' => 'integer',
'default' => 10,
'minimum' => 1,
'maximum' => 100,
),
);
}
}
What do the permission callbacks do?
Each write operation runs its permission check BEFORE the callback fires. current_user_can('publish_posts') checks the WordPress capability system. If the check fails, WordPress returns the error response and never calls your CRUD method.
This is WordPress's equivalent of Laravel middleware or gates. The pattern is identical: check permissions, then execute.
- Save includes/class-portfolio-rest-controller.php.
Wire the controller into your plugin and verify
The controller class exists but WordPress does not know about it yet. You need to require the file and hook the route registration to rest_api_init.
- In developer-portfolio.php, find this line near the top of the file:
define( 'DEV_PORTFOLIO_PATH', plugin_dir_path( __FILE__ ) );
- Add this line directly below it:
require_once DEV_PORTFOLIO_PATH . 'includes/class-portfolio-rest-controller.php';
- Scroll down past the devportfolio_register_meta function and its add_action call. Add this new function and hook below it:
/**
* Register the custom REST API controller.
*/
function devportfolio_register_rest_routes() {
$controller = new DevPortfolio_REST_Controller();
$controller->register_routes();
}
add_action( 'rest_api_init', 'devportfolio_register_rest_routes' );
What does this code do?
The rest_api_init hook fires only when an API request comes in. Inside the callback, you instantiate your controller and call register_routes(), which registers your custom endpoints with WordPress.
- Save developer-portfolio.php.
Before you hit the new endpoint, what do you expect to see? Think about what prepare_item_for_response returns.
- Open a browser tab and navigate to http://localhost:8888/wp-json/developer-portfolio/v1/projects.
You see a JSON array containing your test project. The response includes project_url, github_url, and tech_stack as top-level fields in the response body. No nested meta wrapper.
Seeing a 404 or empty array?
If you get a 404 rest_no_route error, confirm you saved both files and that the require_once path matches your actual file name (class-portfolio-rest-controller.php).
If you see an empty array, make sure your test project from Step 2 is published (not a draft). The query filters by post_status => 'publish'.
help me debug my custom WordPress REST endpoint returning a 404
Why build a custom controller when the built-in endpoint works?
The built-in /wp/v2/portfolio-projects endpoint returns dozens of fields you do not need (rendered content blocks, GUID, link, embedded data). Your custom controller returns exactly the shape your frontend expects.
This is the same reason you build API Resources in Laravel instead of returning raw Eloquent models. You control the contract.
✔️ Awesome, I've got everything!
Great. Double check you have saved both developer-portfolio.php and includes/class-portfolio-rest-controller.php. Confirm both endpoints return data:
- http://localhost:8888/wp-json/wp/v2/portfolio-projects shows meta fields inside the meta object.
- http://localhost:8888/wp-json/developer-portfolio/v1/projects shows meta fields as top-level keys.
ⓧ I'd like to double check the full code
Your developer-portfolio.php should now have, in order:
- The plugin header comment and ABSPATH guard.
- The two define constants.
- The require_once for the controller file.
- devportfolio_register_post_type with 'custom-fields' in supports.
- devportfolio_register_taxonomy (unchanged).
- devportfolio_register_meta (unchanged).
- devportfolio_register_rest_routes hooked to rest_api_init.
- The activation and deactivation hooks (unchanged).
Your includes/class-portfolio-rest-controller.php should have, in order:
- File header, ABSPATH guard, class declaration extending WP_REST_Controller.
- Constructor setting namespace and rest_base.
- register_routes with collection and single-item routes.
- get_items and get_item methods.
- create_item, update_item, delete_item methods.
- prepare_item_for_response returning the shaped data array.
- Three permission check methods.
- get_collection_params and the closing class brace.
Your custom REST controller is live. You now have two endpoints: the built-in WordPress one for standard integrations, and your own namespaced one that delivers exactly the response shape a frontend consumer needs. Next up, you will lock down write operations with Application Passwords.
Authenticate with Application Passwords and Test CRUD
Your custom REST controller is live and returning clean project data. Anyone can read your portfolio projects by hitting the GET endpoint.
Creating, updating, and deleting projects should only be possible for authorised users. WordPress uses Application Passwords with HTTP Basic Auth for REST API authentication. This is the production pattern, analogous to Laravel Sanctum tokens.
In this step, get ready to:
- Generate an Application Password for the admin user.
- Test that unauthenticated write requests are rejected with 403.
- Run the full CRUD cycle with authenticated requests.
Generate an Application Password
Application Passwords are per-user credentials that external tools use to authenticate against the REST API. Each one can be revoked independently without changing the user's login password.
- Return to the WordPress admin at http://localhost:8888/wp-admin.
- Click Users in the left sidebar.
- Click the admin username to open the user profile.
- Scroll down to the Application Passwords section near the bottom of the page.
- In the New Application Password Name field, type Portfolio CLI.
- Click Add New Application Password.
WordPress displays the generated password. It looks like groups of four characters separated by spaces (e.g. abcd EFGH 1234 ijkl MNOP 6789).
- Copy the password and save it somewhere accessible. WordPress shows it only once.
What is an Application Password?
Application Passwords let external tools authenticate against the WordPress REST API using HTTP Basic Auth. Each password is tied to a user account and can be revoked from the same profile page at any time.
Spaces in the password are cosmetic. WordPress strips them before verifying. You can include or omit spaces when passing the password in curl.
Test an authenticated POST request
Before you run the authenticated version, what do you think will happen if you try to POST without any credentials?
- Test an unauthenticated POST by running this command:
curl -X POST http://localhost:8888/wp-json/developer-portfolio/v1/projects -H "Content-Type: application/json" -d "{\"title\":\"Unauthorized Test\"}"
You see a 403 response with the message You do not have permission to create projects. Your permission_callback is doing its job.
Why 403 and not 401?
WordPress returns 403 Forbidden (not 401 Unauthorized) for REST API permission failures. The permission_callback runs after route matching. If the caller lacks the required capability, WordPress rejects the request as forbidden.
- Now create a portfolio project with your Application Password by running this command (replace your-application-password-here with the password you copied):
curl -X POST http://localhost:8888/wp-json/developer-portfolio/v1/projects \
-u "admin:your-application-password-here" \
-H "Content-Type: application/json" \
-d "{\"title\":\"My Laravel App\",\"content\":\"Built with Laravel 11\",\"project_url\":\"https://example.com\",\"github_url\":\"https://github.com/example\",\"tech_stack\":\"Laravel, React, MySQL\"}"
What does this command do?
- -X POST sets the HTTP method to POST (create).
- -u "admin:..." sends HTTP Basic Auth credentials. The -u flag is how curl passes a username and password.
- -H "Content-Type: application/json" tells WordPress the request body is JSON.
- -d "{...}" is the JSON payload containing title, content, and all your custom meta fields.
You see a JSON response with the new project data, including title, project_url, github_url, and tech_stack fields. The HTTP status is 201 Created.
Getting a 403 even with the password?
- Make sure there are no extra quotes wrapping the password. The format after -u is "username:password" with no additional quoting inside.
- Confirm you copied the full password. It is 24 characters (spaces optional).
- Check that Docker Desktop is still running. If the container restarted, Application Passwords persist in the database and still work.
help me debug why my curl request is returning 403 even with Application Password credentials
Complete the CRUD cycle
You have proven that writes require authentication. Now run through the remaining operations to confirm every part of your controller works.
- Note the id value from the 201 response you just received. You will use it in the next commands.
- Fetch all portfolio projects (no credentials needed for reads) by running:
curl http://localhost:8888/wp-json/developer-portfolio/v1/projects
You see a JSON array containing your projects with all meta fields populated.
- Update the tech_stack on the project you just created by running this command (replace ID with your project's numeric ID and your-application-password-here with your password):
curl -X PATCH http://localhost:8888/wp-json/developer-portfolio/v1/projects/ID \
-u "admin:your-application-password-here" \
-H "Content-Type: application/json" \
-d "{\"tech_stack\":\"Laravel, Vue, PostgreSQL\"}"
The response shows the updated project with tech_stack now reading Laravel, Vue, PostgreSQL.
- Delete the project by running:
curl -X DELETE http://localhost:8888/wp-json/developer-portfolio/v1/projects/ID \
-u "admin:your-application-password-here"
The response returns no body with an HTTP 204 No Content status. Your project is permanently deleted.
What does 204 No Content mean?
HTTP 204 means the server processed the request successfully but has nothing to send back. This is the standard response for a successful DELETE. The resource no longer exists.
- Confirm the deletion by fetching the collection again:
curl http://localhost:8888/wp-json/developer-portfolio/v1/projects
The deleted project no longer appears in the response. Your full CRUD cycle is complete: create, read, update, and delete all work through your custom controller with proper permission enforcement.
Seeing 'Project not found' on PATCH or DELETE?
- Double-check the numeric ID in your URL matches the id field from the POST response. If you already deleted the project, create a new one first.
- Make sure you replaced the literal text ID in the URL with the actual number (e.g. /projects/5).
help me debug why my PATCH or DELETE request is returning a Project not found error
Your plugin is fully functional. It registers a Custom Post Type, exposes meta fields through the built-in REST API, serves a custom shaped response through your own controller, and enforces authentication on write operations. Next up, the Secret Mission challenges you to make your plugin extensible for other developers.
Secret mission
Add Extensibility Hooks to Your Plugin
Your plugin works, but can other developers extend it without editing your source code? WordPress Core, WooCommerce, and every major plugin achieve this through custom action and filter hooks. In this mission, you will wire up your own hooks and prove they work by adding a computed field to the REST response from outside the controller.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally using Docker containers, so there are no ongoing costs.
Resources you used:
- Docker containers running WordPress (local only, no cost).
- Local WordPress database with test portfolio content.
- The developer-portfolio plugin directory on your Desktop.
Keep everything running
No action needed. Choose this if you are still actively building on the plugin or plan to continue developing soon.
- Docker Desktop and wp-env will continue running in the background.
- Your WordPress site stays available at http://localhost:8888.
- All test portfolio projects and meta data remain in the local database.
Pause - I'll come back to this later
Stop the Docker containers to free up memory and CPU. Your data is preserved and you can restart anytime.
- In your terminal, navigate to the developer-portfolio folder and run:
wp-env stop
- When you are ready to resume, run wp-env start from the same folder to pick up where you left off.
- You can also quit Docker Desktop from the system tray to free additional resources.
Delete - I don't want to use this again
Remove all containers, the database, and your plugin files to start completely fresh.
- In your terminal, navigate to the developer-portfolio folder and destroy the wp-env environment by running:
wp-env destroy
- Remove the plugin directory from your Desktop by running:
cd ~/Desktop
rm -rf developer-portfolio
- On Windows, use this command instead:
cd ~\Desktop
Remove-Item -Recurse -Force developer-portfolio
- Uninstall the global wp-env package by running:
npm -g uninstall @wordpress/env
- If you no longer need Docker Desktop, uninstall it through your system settings. On Windows, go to Settings, then Apps, find Docker Desktop, and click Uninstall. On macOS, open the Docker Desktop app, click the Docker menu, and select Uninstall.
Nice Work!
Nice Work!
You just built a production-quality WordPress plugin that manages portfolio projects through both the built-in REST API and your own custom namespaced endpoints, secured with Application Password authentication.
You've learned how to:
- Register a Custom Post Type with full REST API support and custom meta fields, understanding how register_post_type maps to a Laravel Model and Migration combined.
- Build a custom WP_REST_Controller subclass with full CRUD, permission callbacks, and clean response shaping at your own namespaced endpoint.
- Authenticate REST API requests using Application Passwords with HTTP Basic Auth, verifying that unauthenticated writes return 403 while authenticated writes succeed.
- (Secret Mission) Created your own action and filter hooks using do_action and apply_filters, making your plugin extensible by other developers using the same pattern WordPress Core uses internally.
Ready to quiz yourself?