In the previous article, we looked at the architecture behind the new sanjibsinha.in and the reason for separating WordPress from the public-facing application.

The idea was straightforward:

WordPress manages the content. A custom PHP application manages the presentation. An API connects the two.

That sounds simple when reduced to one sentence.

The interesting part begins when we actually have to build the API.

For this project, we did not want to scatter REST API callbacks throughout a WordPress theme or write one enormous plugin file containing every piece of application logic.

Instead, we created a small application layer inside WordPress.

The result is a custom plugin called Full Stack App.

Its job is to turn our WordPress installation into a structured backend that can serve posts, categories, applications and application status through a predictable API.

This article walks through how we built that layer.


1. Why Build a Custom Plugin?

WordPress already provides a REST API.

For example, a normal WordPress installation exposes endpoints under:

/wp-json/wp/v2/

That is useful, but it exposes WordPress’s own data model.

Our application has a different concern.

The frontend should not need to understand every internal WordPress detail.

Instead, we want an application-specific API:

/wp-json/fullstack/v1/

This gives us our own namespace and, more importantly, our own contract.

For example:

GET /wp-json/fullstack/v1/status
GET /wp-json/fullstack/v1/posts
GET /wp-json/fullstack/v1/posts/{id}
GET /wp-json/fullstack/v1/categories
GET /wp-json/fullstack/v1/categories/{id}
GET /wp-json/fullstack/v1/apps

The frontend can depend on these endpoints rather than directly depending on WordPress’s internal representation.

That distinction becomes increasingly important as an application grows.


2. The Plugin Is an Application Layer

The plugin is not intended to be a traditional WordPress plugin that adds a small button or modifies a theme.

It is the backend application layer.

The current package looks approximately like this:

fullstack-app/
│
├── fullstack-app.php
├── composer.json
│
├── app/
│   ├── Bootstrap.php
│   │
│   └── Api/
│       ├── Registrar.php
│       ├── Rest.php
│       ├── Status.php
│       ├── Posts.php
│       ├── Categories.php
│       ├── Apps.php
│       ├── ApiResponse.php
│       ├── ApiError.php
│       └── ErrorResponse.php
│
├── tests/
│   ├── api-posts.sh
│   └── api-categories.sh
│
├── bin/
├── vendor/
└── composer.json

This structure is deliberately modular.

Instead of putting everything into fullstack-app.php, the main plugin file acts primarily as the entry point.

The application logic lives under app/.


3. The Plugin Entry Point

Every WordPress plugin needs an entry point.

Ours is:

<?php
/**
 * Plugin Name: Full Stack App
 * Description: Backend application layer for our full-stack web app.
 * Version: 0.1.0
 * Author: Sanjib Deb Sinha
 */

The plugin first checks that it is actually running inside WordPress:

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

This prevents the file from being executed directly outside the WordPress environment.

The plugin then loads Composer’s autoloader:

$autoload = __DIR__ . '/vendor/autoload.php';

if ( ! file_exists( $autoload ) ) {
    return;
}

require_once $autoload;

Finally, it starts the application:

FullStack\App\Bootstrap::boot();

That final line is important.

The plugin does not need to know how every API endpoint works.

It simply starts the application.


4. Using Composer and PSR-4 Autoloading

The plugin uses Composer for autoloading.

The relevant part of composer.json is:

{
  "autoload": {
    "psr-4": {
      "FullStack\\App\\": "app/"
    }
  }
}

This establishes a relationship between namespaces and directories.

For example:

FullStack\App\Api\Posts

maps to:

app/Api/Posts.php

This is much cleaner than manually including dozens of PHP files.

The application can simply refer to classes by namespace and let Composer handle loading them.

That is one of the first places where the plugin begins to look more like a small PHP application than a conventional collection of WordPress functions.


5. Bootstrapping the Application

The next layer is Bootstrap.php.

Its responsibility is intentionally small:

class Bootstrap {

    public static function boot(): void {

        add_action(
            'rest_api_init',
            [ Registrar::class, 'register' ]
        );
    }
}

The application waits for WordPress’s rest_api_init hook.

When WordPress initializes its REST API system, our application tells the Registrar to register its own routes.

The flow is therefore:

WordPress starts
       ↓
Plugin loads
       ↓
Bootstrap::boot()
       ↓
rest_api_init
       ↓
Registrar::register()
       ↓
API modules register routes

This is a small design decision, but it keeps the boot process understandable.


6. The API Registrar

The Registrar acts as the central directory of API modules.

It currently knows about modules such as:

$apis = [
    Rest::class,
    Status::class,
    Posts::class,
    Categories::class,
];

and the application endpoint is also registered through:

Apps::register();

Each API class is responsible for its own routes.

This gives us a useful organizational principle:

One API responsibility, one PHP class.

Instead of one enormous REST controller, we have:

Status
Posts
Categories
Apps

Each class can evolve independently.


7. Creating Our Own API Namespace

The routes are registered under:

fullstack/v1

For example, the status endpoint is:

/wp-json/fullstack/v1/status

This is preferable to placing custom endpoints into a generic namespace.

The v1 portion is particularly important.

It gives us room for future API versions.

We can eventually imagine:

/fullstack/v1/
/fullstack/v2/

without necessarily breaking an existing frontend.

Versioning is a small investment that can save considerable trouble later.


8. The First Endpoint: Status

Before building complicated endpoints, it is useful to have a very simple health/status endpoint.

Our status endpoint returns:

{
    "status": "ok",
    "application": "Full Stack App",
    "version": "0.1.0"
}

The implementation is intentionally simple:

public static function handle(): array {

    return [
        'status'      => 'ok',
        'application' => 'Full Stack App',
        'version'     => '0.1.0',
    ];
}

This endpoint gives us a convenient way to determine whether the application layer is alive.

For example:

GET /wp-json/fullstack/v1/status

If it responds correctly, we know that:

  • WordPress is running
  • the plugin is active
  • Composer autoloading is working
  • the bootstrap process is working
  • REST route registration is working

A tiny endpoint can therefore tell us quite a lot.


9. Building the Posts API

The posts endpoint is where the application begins to become useful.

We created two routes:

GET /posts
GET /posts/{id}

The collection endpoint supports parameters including:

page
per_page
search
category
orderby
order

For example:

/wp-json/fullstack/v1/posts?page=1&per_page=10

or:

/wp-json/fullstack/v1/posts?search=wordpress

or:

/wp-json/fullstack/v1/posts?category=technology

This is much closer to what a frontend application actually needs.


10. Validate at the API Boundary

One of the most important parts of the implementation is validation.

Consider per_page.

We don’t want a client to request:

?per_page=999999

and accidentally force the backend to attempt an enormous query.

The API therefore establishes a boundary:

'per_page' => [
    'default'           => 10,
    'sanitize_callback' => 'absint',
    'validate_callback' => function ( $value ) {
        return (int) $value >= 1 && (int) $value <= 50;
    },
],

The same principle is used for other parameters.

For example, orderby accepts a controlled list:

date
title
ID
modified

And order accepts:

ASC
DESC

This is a much better approach than accepting arbitrary values and hoping that everything behaves correctly.

The API boundary should be strict.


11. Querying WordPress

Once the request has passed validation, the API can construct a WordPress query.

The posts endpoint uses WP_Query:

$query_args = [
    'post_type'      => 'post',
    'post_status'    => 'publish',
    'posts_per_page' => $per_page,
    'paged'          => $page,
    'orderby'        => $orderby,
    'order'          => $order,
];

Optional search and category filters are then added.

This is an important architectural point.

The frontend does not need to know that the backend uses WP_Query.

The API hides that implementation detail.

The frontend asks for:

published posts

The backend decides how to retrieve them.


12. Transforming WordPress Data

Raw WordPress objects are not returned directly.

Instead, the plugin transforms them into our own representation.

The transform() method creates a predictable structure containing fields such as:

id
title
slug
excerpt
content
date
author
categories
featured_image
link

For example:

{
    "id": 123,
    "title": "Example Article",
    "slug": "example-article",
    "excerpt": "An example article...",
    "content": "<p>...</p>",
    "date": "2026-08-20T10:00:00+00:00",
    "author": {
        "id": 1,
        "name": "Author"
    },
    "categories": [],
    "featured_image": null,
    "link": "https://example.com/example-article/"
}

This transformation is one of the most valuable parts of the architecture.

The frontend receives an application-oriented data structure instead of a raw WP_Post object.


13. Featured Images Are Transformed Too

Images are another example of why transformation is useful.

Rather than forcing the frontend to understand WordPress attachment IDs and metadata, the API returns a compact object:

{
    "id": 42,
    "url": "https://example.com/image.jpg",
    "alt": "Example image"
}

The frontend can simply ask:

What is the image URL?
What is its alternative text?

It does not need to know how WordPress stored the attachment.

Again, the API is acting as a translator.


14. Pagination Is Part of the Contract

A collection endpoint needs more than the current page of data.

The frontend also needs to know how much data exists.

Our response therefore has a structure like:

{
    "data": [
        {}
    ],
    "meta": {
        "page": 1,
        "per_page": 10,
        "total": 42,
        "total_pages": 5
    }
}

This allows the frontend to implement pagination without making assumptions about the backend.

The metadata tells it:

  • which page it requested
  • how many items were requested
  • how many total records exist
  • how many pages exist

That is a clean API contract.


15. A Standard Success Response

To keep the API consistent, we created ApiResponse.

Its purpose is simple:

public static function success(
    mixed $data,
    ?array $meta = null,
    int $status = 200
): WP_REST_Response

A successful response always begins with:

{
    "data": ...
}

and optionally contains:

{
    "data": ...,
    "meta": ...
}

This might seem like a small abstraction.

It is.

But small abstractions become useful when they are repeated throughout an application.

If every endpoint creates its response differently, the frontend becomes harder to maintain.


16. Standardizing Errors

Success responses are only half of an API.

Errors matter just as much.

We created ApiError to provide a standard way of generating WordPress errors:

return ApiError::make(
    'fullstack_post_not_found',
    'Post not found.',
    404
);

The resulting API response is normalized by Rest.

The final JSON structure becomes:

{
    "error": {
        "code": "fullstack_post_not_found",
        "message": "Post not found.",
        "status": 404
    }
}

This gives the frontend three useful pieces of information:

code
message
status

The HTTP status is still meaningful, but the structured JSON error also makes the API easier for application code to consume.


17. Why Normalize WordPress Errors?

WordPress has its own WP_Error system.

There is nothing wrong with using it internally.

The problem is that the frontend should not have to understand every possible internal WordPress error format.

Our Rest class therefore intercepts errors belonging to our API namespace:

/fullstack/v1/

and converts them into our own response format.

Conceptually:

WordPress Error
      ↓
   ApiError
      ↓
 Error Normalizer
      ↓
JSON API Error

This gives us a stable external contract while still using WordPress’s native mechanisms internally.


18. Categories Get Their Own Endpoint

Posts and categories are related, but they are different resources.

So categories receive their own API class:

Categories.php

and routes:

GET /categories
GET /categories/{id}

The collection endpoint supports:

hide_empty

and returns data such as:

{
    "id": 4,
    "name": "Technology",
    "slug": "technology",
    "count": 12
}

Again, the frontend receives exactly the information it needs.

It does not need to understand the entire WordPress taxonomy object.


19. Applications Become a Backend Resource

The most application-specific part of the backend is the application repository.

We created a custom post type:

app_repository

WordPress therefore becomes the administration interface for managing downloadable applications.

The plugin adds an Applications section to the WordPress dashboard.

An application can contain information such as:

Title
Description
Version
Operating System
ZIP file
Featured image

The backend then exposes those applications through:

GET /wp-json/fullstack/v1/apps

The API transforms the WordPress custom post into something much more useful to the frontend:

{
    "id": "example-application",
    "title": "Example Application",
    "version": "1.0.0",
    "description": "Example application description.",
    "os": "Linux / Ubuntu",
    "file": "https://example.com/example.zip",
    "date": "Aug 2026"
}

This is a good example of the architecture doing exactly what we wanted.

WordPress provides the administrative machinery.

Our application layer provides the API.

The frontend simply consumes the resulting application data.


20. The Application File Is Stored Through WordPress Media

The application ZIP file is not hard-coded into the frontend.

Instead, the WordPress administrator can associate an uploaded media attachment with an application.

The plugin stores the attachment ID as:

app_file_id

When the API transforms the application, it resolves that ID into a usable URL.

The frontend therefore receives:

file

rather than having to understand WordPress attachment metadata.

This keeps the frontend pleasantly ignorant of the backend’s storage mechanism.

And that ignorance is a feature.


21. Restricting the WordPress Public Frontend

There was another requirement in our architecture.

WordPress is the backend.

The custom PHP application is the public website.

So we do not want an ordinary visitor accidentally accessing the WordPress-rendered frontend.

The plugin therefore restricts normal frontend requests while allowing:

  • WordPress administration
  • WordPress login
  • REST API requests

The important exception is the REST API.

The plugin explicitly checks for REST requests before applying the frontend restriction.

This produces the intended model:

Visitor
   │
   ├── WordPress frontend ──► Restricted
   │
   ├── WordPress Admin ─────► Available to authorized users
   │
   └── REST API ────────────► Available to frontend application

The WordPress installation becomes a backend service rather than the public website.


22. CORS and the Separate Frontend

Because the PHP frontend can run on a different origin during development, the backend needs to allow the frontend to communicate with the API.

The plugin therefore adds CORS headers to REST responses.

The development architecture can consequently look like:

Frontend
https://frontend.example
        │
        │ HTTP
        ▼
WordPress Backend
https://backend.example
        │
        ▼
REST API

This becomes particularly useful when developing the frontend and backend as separate projects.

In production, CORS should be configured according to the actual deployment requirements rather than blindly allowing every origin.

The current development implementation uses a permissive configuration because the goal was to make the separated development environment communicate cleanly.


23. Testing the API

A backend is not finished merely because an endpoint returns something in the browser.

We also created shell-based API tests.

For example:

tests/
├── api-posts.sh
└── api-categories.sh

These tests use curl to make actual HTTP requests and jq to inspect the JSON responses.

That gives us a useful testing pattern:

Shell script
     ↓
HTTP request
     ↓
WordPress REST API
     ↓
JSON response
     ↓
jq assertion
     ↓
PASS / FAIL

This is particularly useful for a headless application because the API itself is the contract between two independently developed systems.

If that contract breaks, the frontend can break even when the WordPress dashboard appears perfectly healthy.


24. What We Test

The tests check more than HTTP status codes.

For example, the posts tests verify things such as:

200 response
data exists
data is an array
meta exists
pagination values exist
post fields exist
IDs are numeric
titles are strings

They also test failure cases:

missing post
invalid parameters
invalid pagination
invalid ordering

Similarly, the category tests verify:

category collection
single category
missing category
category structure
field types
error structure

This is important because an API contract is about shape, not merely about whether the server says 200 OK.


25. The Resulting Backend Flow

At this point the entire backend request lifecycle can be visualized like this:

                  HTTP Request
                       │
                       ▼
              ┌────────────────┐
              │    WordPress    │
              └───────┬────────┘
                      │
                      ▼
              ┌────────────────┐
              │ Full Stack App │
              │    Plugin      │
              └───────┬────────┘
                      │
                      ▼
              ┌────────────────┐
              │  API Registrar │
              └───────┬────────┘
                      │
          ┌───────────┼────────────┐
          ▼           ▼            ▼
       Posts      Categories      Apps
          │           │            │
          └───────────┼────────────┘
                      ▼
                 WordPress
                    Data
                      │
                      ▼
              Transformation
                      │
                      ▼
               API Response
                      │
                      ▼
                    JSON

The frontend never needs to know how the data was retrieved.

It simply consumes the JSON.


26. Why This Structure Matters

At first glance, this might look like unnecessary abstraction.

Why create:

Bootstrap
Registrar
Posts
Categories
Apps
ApiResponse
ApiError
Rest

instead of putting everything into one plugin file?

Because we are not building a five-line WordPress customization.

We are building an application.

As the number of resources increases, a single-file plugin quickly becomes difficult to understand.

A modular structure gives us room to add:

Users
Search
Authentication
Comments
Products
Orders
Documentation
Subscriptions

without turning the backend into an enormous procedural file.

The architecture therefore anticipates growth without requiring us to build an enormous framework on day one.


27. WordPress Remains WordPress

There is also an important philosophical point here.

We did not replace WordPress.

We extended it.

The plugin uses WordPress’s own mechanisms:

Actions
REST API
WP_Query
Custom Post Types
Post Meta
Media Library
WP_Error
WP_REST_Response

The custom application layer sits on top of those capabilities.

That is one of the reasons this architecture is practical.

We are not rebuilding a CMS.

We are using an existing CMS as infrastructure.


28. The Backend Is Now an API Provider

After building the plugin, the role of the WordPress installation changed substantially.

Before:

WordPress
    ↓
Theme
    ↓
Website

Now:

WordPress
    ↓
Full Stack App Plugin
    ↓
Application API
    ↓
PHP Frontend
    ↓
Website

That change is the heart of the project.

The backend is no longer primarily concerned with how a page looks.

Its job is to provide reliable data.

The frontend can then decide how that data should look.


29. What We Have Achieved

With a relatively small amount of PHP, we have turned an ordinary WordPress installation into a specialized application backend.

It can now provide:

  • application status
  • posts
  • individual posts
  • categories
  • individual categories
  • application repository entries
  • downloadable application files
  • structured success responses
  • structured error responses
  • pagination metadata
  • filtering and sorting
  • API-level validation

More importantly, the backend has a clear responsibility.

It stores and manages information.

It exposes that information through an API.

It does not need to know what the final frontend looks like.


30. The Next Layer: Building the Frontend

At this point we had something interesting:

WordPress
    +
Custom API

But an API by itself is not a website.

We still needed a public-facing application capable of turning that JSON data into pages that people could actually read and use.

That became the second half of the project.

Instead of creating another WordPress theme, we built a lightweight PHP frontend using:

PHP
HTML
CSS
JavaScript

The frontend would consume the backend API and take responsibility for:

  • public URLs
  • page rendering
  • article presentation
  • application presentation
  • navigation
  • layout
  • styling
  • browser-side interaction

In the next article, we will build that side of the story.

We will look at how we created the PHP frontend, how it communicates with the WordPress API, how the pages are structured, and how a relatively small collection of PHP, HTML, CSS and JavaScript files became the public face of the application.

The backend provides the data.

Now we need to give that data a body.