Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/Ahondev/portfolio-v2/llms.txt

Use this file to discover all available pages before exploring further.

WP SSR Framework replaces the WordPress template pipeline with a bespoke MVC dispatch loop that runs entirely inside a must-use plugin. Understanding the architecture makes it much easier to extend the framework, diagnose unexpected behaviour, or reason about performance. This page traces the complete lifecycle of a single browser request from the moment WordPress loads to the moment React mounts in the browser — and then follows an in-page SPA navigation triggered by the client.

High-level overview

Browser request


WordPress bootstrap
      │  (mu-plugins autoloader)

wp-ssr.php — Kernel::configure()->withProviders([...])->boot()

      ├─► ProviderManager::registerProviders()   (register + boot each provider)
      ├─► EloquentManager::init()                (hook: init, priority 10)
      ├─► ApiRouter::init()                      (hook: rest_api_init, priority 30)
      ├─► PostType::init()                       (hook: init, priority 20)
      ├─► ComposerManager::registerComposers()   (hook: wp_loaded, priority 1)
      └─► WebRouter::init()->handleRequest()     (hook: wp_loaded, priority 5)

                ├─► Bot? → serveStaticCache() → readfile(cache/client/static/*.html) → exit
                ├─► Static route match → Controller::method() → WebController::view() → exit
                └─► CPT route match  → Controller::archive()|single() → WebController::view() → exit


                                   $__wp_data__ = [view, seo, data, assets]

                              ┌─────────────────┴──────────────────┐
                              │  ?json=1 (SPA fetch)               │  HTML (first load)
                              ▼                                     ▼
                      JSON response                       src/SSR/client.php
                      {view, seo, data}                   └─ <script>window.__wp_data__</script>
                                                          └─ <div id="root"></div>


                                                           React boots, reads window.__wp_view__
                                                           mounts view component with window.__wp_data__

Step-by-step request lifecycle

1

WordPress boots — Kernel registers all service providers

Bedrock’s mu-plugin autoloader includes wp-ssr.php on every request before WordPress finishes loading. The bootstrap file calls Kernel::configure()->withProviders([...])->boot(). The Kernel first checks that ACF Pro is active, then delegates to ProviderManager::registerProviders(), which calls register() on every provider before calling boot() on any of them — mirroring Laravel’s two-phase provider lifecycle.
// src/Kernel.php
public function boot(): Kernel
{
    // Phase 1: register all providers
    ProviderManager::registerProviders($this->providers);

    // Phase 2: register WordPress action hooks for sub-systems
    add_action('init', fn () => new EloquentManager, 10);
    add_action('rest_api_init', fn () => (new ApiRouter('api/v1'))->init(), 30);
    add_action('init', fn () => PostType::init(), 20);
    add_action('after_setup_theme', fn () => Option::init());
    add_action('admin_init', fn () => WP_Options::register(), 40);
    add_action('wp_loaded', fn () => ComposerManager::registerComposers(), 1);
    add_action('wp_loaded', fn () => WebRouter::init()->handleRequest(), 5);

    return $this;
}
The default service providers and what they do:
ProviderResponsibility
AppServiceProviderDisables WP frontend theme rendering, hardens security, rewrites /admin, adds CORS headers
RateLimitServiceProviderTransient-based rate limiter: 100 requests per IP per route per 60 seconds
CacheServiceProviderRegisters object-cache helpers used by controllers via $this->remember()
AnalyticsServiceProviderWires lightweight page-view tracking
SEOServiceProviderRegisters ACF field groups (title, description, keywords, OG image, author) and the SEO dashboard
RoutingServiceProviderAdds a “Route” column to the WordPress Pages admin list
SSGServiceProviderRegisters the Static Site Generator admin page, AJAX handlers, and daily cron event
2

WebRouter::init() loads configuration/routes/web.php

On the wp_loaded hook (priority 1), ComposerManager::registerComposers() scans app/Composers/ and pre-computes shared view data. Then on wp_loaded (priority 5), WebRouter::init() instantiates the router singleton and require_onces the route definition file.
// src/Routing/Web/WebRouter.php
public static function init(): WebRouter
{
    $router = new self;
    require_once plugin_root('/configuration/routes/web.php');
    return $router;
}
Each Route::get() call inside web.php creates a WebRouteDefinition and stores it in $this->routes keyed by its URL path. Each Route::CPT() call stores a [postType, controller] pair in $this->cptRoutes. In development mode, both calls also call wp_insert_post() to create matching WordPress pages so SEO metadata can be managed through the admin.
3

handleRequest() matches the URL to a controller

After the route file is loaded, handleRequest() is called immediately on the same init() return value. It compares the current URI against the registered routes and CPT prefixes:
// src/Routing/Web/WebRouter.php (abridged)
public function handleRequest(): bool
{
    $uri = $this->getCurrentURI();

    // Bail out for WordPress-internal paths
    $wp_routes = ['/wp/', '/api/', '/uploads/', '/admin', '/client'];
    if (!empty(array_filter($wp_routes, fn ($r) => str_starts_with($uri, $r)))) {
        return false;
    }

    // Bots get static HTML from /cache/client/static/
    if (BotDetector::isBot() && $this->serveStaticCache($uri)) {
        exit;
    }

    // Exact match → static route
    if (array_key_exists($uri, $this->routes)) {
        $route = $this->routes[$uri];
        echo (new $route->controller)->{$route->method}();
        exit;
    }

    // Prefix match → CPT archive or single
    foreach ($this->cptRoutes as $base => $config) {
        if ($uri === $base) {
            echo (new $config['controller'])->archive();
            exit;
        }
        if (str_starts_with($uri, $base.'/')) {
            $slug  = trim(substr($uri, strlen($base)), '/');
            $wpPost = get_posts(['name' => $slug, 'post_type' => $config['postType']::$model, 'posts_per_page' => 1])[0] ?? null;
            echo (new $config['controller'])->single(
                $wpPost ? new $config['postType']($wpPost) : null
            );
            exit;
        }
    }

    return false;
}
Any URI not matched by the custom router falls through to normal WordPress handling. This means /wp/wp-login.php, the REST API (/api/v1/), and media uploads all continue to work without any special configuration.
4

Controller calls $this->view() with view name and data

Controllers extend WebController. For static page routes the method signature is straightforward:
// app/Controllers/Web/HomeController.php
class HomeController extends WebController
{
    public function home(): string
    {
        $services = $this->remember('composer_services_v1', 300, function () {
            return Service::query()->all();  // EloquentCPT QueryBuilder
        });

        return $this->view('home', [
            'services' => $services,
        ]);
    }
}
CPT controllers implement CPTControllerInterface which requires both archive() and single(?EloquentCPT $post):
// app/Controllers/Web/ServiceController.php
class ServiceController extends WebController implements CPTControllerInterface
{
    public function archive(): string|bool
    {
        return $this->view('services', [
            'services' => Service::query()->all(),
        ]);
    }

    public function single(?EloquentCPT $post): string|bool
    {
        return $this->postView('services_single', $post, [
            'services' => Service::query()->all(),
        ]);
    }
}
EloquentCPT models are instantiated from a WP_Post object and automatically map every ACF field to a typed property via get_fields($id):
// src/Eloquent/EloquentCPT.php (abridged)
class EloquentCPT
{
    public int    $id;
    public string $title;
    public string $slug;
    public string $type;
    public string $date;

    public function __construct(\WP_Post $post)
    {
        $this->id    = $post->ID;
        $this->type  = $post->post_type;
        $this->title = $post->post_title;
        $this->slug  = $post->post_name;
        $this->date  = $post->post_date;
        $this->initACFFields();   // maps every ACF field as a dynamic property
    }

    public static function query(): QueryBuilder
    {
        return QueryBuilder::make(static::$model, (new \ReflectionClass(get_called_class()))->getShortName());
    }
}
5

WebController::view() serialises $__wp_data__ as inline JSON

WebController::view() gathers four things and packs them into a single PHP array:
  1. view — the view name string (e.g. 'home', 'services_single')
  2. seo — pulled from ACF page meta or post-type fields (seo_title, seo_description, seo_keywords, seo_author); also includes the current url and the site name from get_bloginfo('name')
  3. data — the array passed by the controller, merged with Composer data for this view
  4. assets — from Vite::instance()->assets(): dev-server URLs in development, manifest-hashed production paths in production
// src/Controllers/WebController.php (abridged)
public function view(string $view, array $data = [], ?EloquentCPT $post = null): string
{
    $assets       = Vite::instance()->assets();
    $composerData = ComposerManager::getView($view);
    $data         = array_merge($data, $composerData);

    $seo = $post
        ? ['site_title' => get_bloginfo('name'), 'page_title' => $post->seo_title ?? '', ...]
        : ['site_title' => get_bloginfo('name'), 'page_title' => get_post_meta($page?->ID, 'seo_title', true), ...];

    $__wp_data__ = ['view' => $view, 'seo' => $seo, 'data' => $data, 'assets' => $assets];

    // SPA JSON mode — return only data, no HTML
    if (isset($_GET['json']) && $_GET['json'] === '1') {
        header('Content-Type: application/json; charset=utf-8');
        return json_encode(['view' => $view, 'seo' => $seo, 'data' => $data],
            JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
    }

    // HTML mode — render the shell template
    ob_start();
    require plugin_root('/src/SSR/client.php');
    return ob_get_clean();
}
The HTML shell (src/SSR/client.php) inlines the data as three global JavaScript variables:
<script>
  window.__wp_data__  = {"services": [...], "nav": [...]};
  window.__wp_view__  = "home";
  window.__wp_seo__   = {"site_title": "My Site", "page_title": "Accueil", ...};
</script>
<div id="root"></div>
SEO <meta> tags, Open Graph tags, the canonical URL, and asset <link>/<script> tags are also injected server-side so the page is fully crawlable even before React hydrates.
6

React boots, reads window.__wp_view__, and mounts the view

On the client side, main.tsx reads window.__wp_view__ to determine which React component to render and passes window.__wp_data__ as its props. The view string acts as a simple lookup key into a view registry:
// web/client/src/main.tsx (conceptual)
import React from 'react';
import ReactDOM from 'react-dom/client';
import { views } from './views';

const view     = window.__wp_view__;
const data     = window.__wp_data__;
const ViewComp = views[view];

ReactDOM.createRoot(document.getElementById('root')!).render(
  <ViewComp {...data} />
);
TanStack Query, React Router DOM, and any global providers are initialised here. Because all data is already inlined in window.__wp_data__, the initial render is synchronous — there is no loading state or waterfall fetch on first paint.
7

SPA navigation — wp-router fetches ?json=1 for new pages

After the first load the React SPA intercepts anchor clicks via a custom router layer. Instead of triggering a full page reload, it fetches the target URL with ?json=1 appended:
GET /services/my-service?json=1
The WordPress server processes this through the same routing pipeline as a normal request. When WebController::view() detects $_GET['json'] === '1', it returns a lightweight JSON payload instead of the HTML shell:
{
  "view": "services_single",
  "seo": {
    "site_title": "My Site",
    "page_title": "My Service",
    "description": "...",
    "url": "/services/my-service"
  },
  "data": {
    "post": { "id": 42, "title": "My Service", "slug": "my-service", ... },
    "services": [...]
  }
}
The SPA receives this JSON, looks up views["services_single"], swaps the mounted component, and updates document.title and <meta> tags from the seo object — all without a full page reload. The transition is instant because the JSON payload is a fraction of the size of the HTML shell.
The ?json=1 API is also used by the Static Site Generator (SSG) to crawl and pre-render every public URL. The SSG triggers this endpoint from the WordPress admin and stores the resulting HTML in /cache/client/static/.
8

Bot detection — crawlers get pre-rendered static HTML

Before any controller is invoked, BotDetector::isBot() inspects the User-Agent header against a list of 47 known crawler and AI-agent signatures:
// src/Routing/Web/BotDetector.php (abridged)
public static function isBot(): bool
{
    $ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
    if (empty($ua)) {
        self::$isBot = false;
        return false;
    }

    $ua = strtolower($ua);

    $keywords = [
        'Googlebot', 'bingbot', 'GPTBot', 'ChatGPT', 'Claude-Web',
        'PerplexityBot', 'facebookexternalhit', 'Twitterbot', 'LinkedInBot',
        'bot', 'crawl', 'spider', 'curl', 'wget',
        // …47 total
    ];

    foreach ($keywords as $keyword) {
        if (str_contains($ua, $keyword)) {
            self::$isBot = true;
            return true;
        }
    }

    // Third-party plugins can extend detection
    self::$isBot = apply_filters('ahon_is_bot', false, $ua);
    return self::$isBot;
}
When a bot is detected, serveStaticCache() looks for a pre-baked HTML file in /cache/client/static/ keyed by the URL slug:
private function serveStaticCache(string $uri): bool
{
    if (isset($_GET['json']) && $_GET['json'] == '1') {
        return false;  // Never serve stale cache for JSON requests
    }

    $slug     = trim($uri, '/') ?: 'home';
    $slug     = end(explode('/', $slug));       // last path segment
    $filePath = $this->cachePath.'/'.sanitize_file_name($slug).'.html';

    if (!file_exists($filePath) || !is_readable($filePath)) {
        return false;  // Fall through to normal controller dispatch
    }

    header('Content-Type: text/html; charset=UTF-8');
    header('X-SSG-Cache: HIT');

    // Support conditional GET (304 Not Modified)
    $lastModified = filemtime($filePath);
    header('Last-Modified: '.gmdate('D, d M Y H:i:s', $lastModified).' GMT');
    if (strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE'] ?? '') >= $lastModified) {
        header('HTTP/1.1 304 Not Modified');
        return true;
    }

    readfile($filePath);
    return true;
}
If no cache file exists, the request falls through to normal controller dispatch — bots receive a live server-side render with full SEO metadata. The X-SSG-Cache: HIT response header lets you verify cache hits in your CDN or monitoring dashboard.
Trigger a full site regeneration from WP Admin → SSG to pre-bake all public pages. The SSG admin panel shows per-page cache status, supports bulk regeneration with a progress bar, and can be scheduled to run daily via WordPress cron.

Component map

The diagram below shows how the major classes relate to each other:
Kernel
  └─► ProviderManager
        └─► [AppServiceProvider, RateLimitServiceProvider,
             CacheServiceProvider, AnalyticsServiceProvider,
             SEOServiceProvider, RoutingServiceProvider, SSGServiceProvider]

WebRouter (singleton)
  ├─► WebRouteDefinition[]   (from Route::get())
  ├─► cptRoutes[]            (from Route::CPT())
  └─► handleRequest()
        ├─► BotDetector::isBot()
        │     └─► serveStaticCache()
        └─► WebController::view()
              ├─► Vite::instance()->assets()
              ├─► ComposerManager::getView()
              └─► src/SSR/client.php  →  window.__wp_data__ / __wp_view__ / __wp_seo__

ApiRouter (singleton)
  ├─► ApiRouteDefinition[]   (from Route::post() / Route::get())
  └─► register_rest_route()  (WordPress REST API, mounted at /api/v1/)

EloquentCPT
  ├─► QueryBuilder::make()   (wraps get_posts())
  └─► initACFFields()        (auto-maps ACF fields as dynamic properties)

API rate limiting

All REST endpoints under /api/v1/ are protected by the RateLimitServiceProvider, which hooks into rest_pre_dispatch and uses WordPress transients to track per-IP, per-route request counts:
// app/Providers/RateLimitServiceProvider.php
add_filter('rest_pre_dispatch', function ($result, $server, $request) {
    $ip  = $_SERVER['HTTP_CF_CONNECTING_IP']
        ?? $_SERVER['HTTP_X_FORWARDED_FOR']
        ?? $_SERVER['REMOTE_ADDR'];

    $key   = 'rate_' . md5($ip . $request->get_route());
    $count = get_transient($key) ?: 0;

    if ($count > 100) {
        return ['success' => false, 'code' => 429, 'error' => ['message' => 'Too many requests']];
    }

    set_transient($key, $count + 1, 60);
    return $result;
}, 10, 3);
The limit is 100 requests per IP per route per 60-second window. The HTTP_CF_CONNECTING_IP header is checked first so the real visitor IP is used when the site sits behind Cloudflare.

Build docs developers (and LLMs) love