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
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:| Provider | Responsibility |
|---|
AppServiceProvider | Disables WP frontend theme rendering, hardens security, rewrites /admin, adds CORS headers |
RateLimitServiceProvider | Transient-based rate limiter: 100 requests per IP per route per 60 seconds |
CacheServiceProvider | Registers object-cache helpers used by controllers via $this->remember() |
AnalyticsServiceProvider | Wires lightweight page-view tracking |
SEOServiceProvider | Registers ACF field groups (title, description, keywords, OG image, author) and the SEO dashboard |
RoutingServiceProvider | Adds a “Route” column to the WordPress Pages admin list |
SSGServiceProvider | Registers the Static Site Generator admin page, AJAX handlers, and daily cron event |
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. 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.
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());
}
}
WebController::view() serialises $__wp_data__ as inline JSON
WebController::view() gathers four things and packs them into a single PHP array:
view — the view name string (e.g. 'home', 'services_single')
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')
data — the array passed by the controller, merged with Composer data for this view
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.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. 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/.
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.