Running Laravel and WordPress Together: How We Migrated a Monolith One Route at a Time
Migrating a WordPress monolith powering 20 sites and millions of paying customers to Laravel is no small feat. We wanted a seamless transition without disrupting the platform. Here’s how we made Laravel and WordPress coexist, moving one route at a time.
To do this, we built a system that lets Laravel and WordPress coexist in the same request lifecycle — with full access to Laravel's services from within WordPress, and Laravel routes that can opt in or out of WordPress behavior as needed.
Here's how we made it work.
Step 1: Rename Laravel's __() Helper Function
Both Laravel and WordPress define a global __()
function for localization, which can cause conflicts. To resolve this, we created a ComposerScripts::renameHelperFunctions hook to rename Laravel’s __()
to ___()
immediately after Composer generates the autoloader:
// Rename Laravel's __() helper to ___() to avoid conflict with WordPress
$content = file_get_contents($helpersPath);
$content = str_replace("function_exists('__')", "function_exists('___')", $content);
$content = str_replace('function __(', 'function ___(', $content);
file_put_contents($helpersPath, $content);
This way, WordPress can safely use __()
, and Laravel's helper remains accessible as ___()
.
Step 2: Bootstrap Laravel from WordPress
In wp-config.php
, after including vendor/autoload.php
(to load Composer dependencies), we initialize Laravel with a custom bootstrapper:
// Load Composer's autoloader
require_once __DIR__ . '/vendor/autoload.php';
// Bootstrap Laravel in WordPress
require_once __DIR__ . '/path-to/wp-laravel-bootstrapper.php';
Then, in wp-laravel-bootstrapper.php
:
// Initialize Laravel application
$app = require_once '/path-to/bootstrap/app.php';
$kernel = $app->make(Illuminate\Contracts\Http\Kernel::class);
$request = Illuminate\Http\Request::capture();
// Handle WP-Admin URLs to prevent Laravel from misinterpreting them as homepage requests
if (str_contains($request->getRequestUri(), '/wp/')) {
$request = Illuminate\Http\Request::create('/wp-admin');
}
$app->instance('request', $request);
Facade::clearResolvedInstance('request');
$kernel->bootstrap($request);
Step 3: Handling WordPress and Laravel Routes
We prioritize Laravel routes over WordPress to allow seamless integration of new functionality. First, we check if the request matches a Laravel route:
// Check if the request matches a Laravel route; if not, WordPress handles it
try {
$route = $app->make('router')->getRoutes()->match($request);
} catch (Exception) {
// No Laravel route matched, so WordPress takes over
return;
}
We then pass the route to a custom LaravelRequestHandler
class that decides how to handle the request based on the matched route and the current WordPress context.
app(\App\WordPress\LaravelRequestHandler::class)->init($kernel, $request, $route);
Step 4: Controlling the Lifecycle with LaravelRequestHandler
We developed a LaravelRequestHandler class to manage Laravel’s behavior within a WordPress request, deciding whether Laravel runs early, defers to WordPress, or takes full control of the response.
Here's what it enables:
- Skipping WordPress entirely for full Laravel routes
- Allowing WordPress to initialize (and even run WP_Query) before handing control to Laravel
- Disabling plugins or REST API selectively
As the first MU plugin, we add a file mu-plugins/00-laravel.php
, and run:
app(\App\WordPress\LaravelRequestHandler::class)->executeRoute();
This executes the matched Laravel route — or lets WordPress do its thing depending on the context.
Here is a breakdown of the LaravelRequestHandler
class:
namespace App\WordPress;
use App\Http\Middleware\ShortInit;
use App\Http\Middleware\WordPressWithPlugins;
use Illuminate\Contracts\Http\Kernel;
use Illuminate\Http\Request;
use Illuminate\Routing\Route;
class LaravelRequestHandler
{
public bool $executeRoute = false;
public string $loadLaravelAction = 'plugins_loaded';
public ?Kernel $kernel = null;
public ?Request $request = null;
public ?Route $route = null;
private array $middlewareWithShortInit = [
ShortInit::class,
'short-init',
];
private array $middlewareWithPlugins = [
WordPressWithPlugins::class,
'wordpress-with-plugins',
'wordpress-with-plugins:run-query',
];
private array $middlewareWithWpQuery = [
'wordpress-with-plugins:run-query',
];
// Initialize LaravelRequestHandler with kernel, request, and route
public function init(Kernel $kernel, Request $request, Route $route): void
{
$this->executeRoute = true;
$this->kernel = $kernel;
$this->request = $request;
$this->route = $route;
}
public function loadLaravelAndExit(): void
{
if ($this->loadLaravelAction == 'wp_loaded') {
$this->parseRequest();
}
$response = $this->kernel->handle($this->request);
$body = $response->send();
$this->kernel->terminate($this->request, $body);
exit;
}
public function executeRoute(): void
{
if (! $this->executeRoute) {
return;
}
if ($this->shouldExitEarly()) {
$this->loadLaravelAndExit();
return;
}
add_action($this->loadLaravelAction, $this->loadLaravelAndExit(...), 1, PHP_INT_MAX);
}
private function parseRequest(): void
{
global $wp;
$wp->init();
$parsed = $wp->parse_request();
if ($this->shouldRunWpQuery()) {
$this->runWpQuery($parsed);
}
_wp_admin_bar_init();
}
private function runWpQuery($parsed): void
{
global $wp, $wp_query, $wp_the_query, $post;
if ($parsed) {
$wp->query_posts();
$wp->register_globals();
}
do_action_ref_array('wp', [&$wp]);
if (! isset($wp_the_query)) {
$wp_the_query = $wp_query;
}
if ($post) {
setup_postdata($post);
}
}
private function shouldExitEarly(): bool
{
return $this->hasMiddleware($this->middlewareWithShortInit);
}
private function hasMiddleware(array $middlewareToCheck): bool
{
$middleware = $this->route?->gatherMiddleware();
if (! $middleware) {
return false;
}
foreach ($middleware as $m) {
if (in_array($m, $middlewareToCheck)) {
return true;
}
}
return false;
}
private function shouldRunWpQuery(): bool
{
return $this->hasMiddleware($this->middlewareWithWpQuery);
}
}
🧪 Controlling Timing with loadLaravelAction
The LaravelRequestHandler
uses the loadLaravelAction
property to control when Laravel processes the request, ensuring compatibility with the WordPress lifecycle.
For example:
plugins_loaded
→ Laravel runs before WordPress really startswp_loaded
→ Laravel runs after WP_Query has done its work
By default, routes using the short-init
middleware skip WordPress entirely, while others (like wordpress-with-plugins
) allow a hybrid response.
This flexibility let us move piece by piece from WordPress to Laravel without breaking the rest of the system.
✅ The Result
The result is a robust hybrid application where:
- Laravel boots seamlessly on every request.
- Laravel routes override WordPress behavior as needed.
- Existing WordPress pages function without interruption.
This approach enabled a gradual migration, improving scalability and maintainability.