Your WordPress site works. Content editors are happy, the blog ranks, and the theme looks fine. But one part of it has outgrown everything plugins can offer. Maybe it's a booking system held together by three add-ons, a member dashboard full of custom fields, or a quote calculator that times out under load.
The obvious fix is to rebuild the whole thing in Laravel. The obvious problem is that a full rewrite means months of frozen features, a risky launch day, and an SEO gamble on every URL at once.
There is a calmer way. The strangler pattern lets you move one WordPress feature to Laravel at a time, on the same domain, with the same URLs, and with a rollback that takes a single config change. This post walks through a working Laravel migration plan: routing with Nginx, sharing the login, handling data, protecting SEO and cutting over safely.
What the Strangler Fig Pattern Means for a WordPress Site
Martin Fowler named this approach after strangler figs, vines that grow around a host tree, put down their own roots and eventually replace it. In Martin Fowler's original strangler fig write-up, the idea is simple: instead of replacing a legacy system in one go, you build the new system around its edges and let it take over piece by piece.
For a WordPress site, that translates into three parts:
- A routing layer in front of both apps. Here, that's Nginx.
- Laravel taking over specific URL paths, one feature at a time.
- WordPress shrinking with every move, until it's just a content system, or gone completely.
Visitors never see the seam. They browse /blog (WordPress), click through to /booking (Laravel), and stay on the same domain the whole time.
Strangler vs Big-Bang Rewrite
- Risk: a rewrite bets everything on one launch. The strangler approach risks one feature at a time.
- Feedback: you ship real Laravel code to real users in weeks, not months.
- SEO: URLs stay the same, so there's no mass redirect project.
- Team load: WordPress keeps running normally, so content and marketing work never pauses.
Should You Move to Laravel at All?
Before planning a migration, make sure it solves a real problem.
Good reasons to move a feature to Laravel:
- Custom business logic that plugins can't express cleanly
- Complex data relationships squeezed into post meta
- Multi-step forms, workflows, approvals or queues
- An API that mobile apps or partners depend on
- Plugin stacks that conflict on every update
- A need for proper automated tests
Weak reasons:
- The site is mostly a blog and editors only need to publish content
- "Laravel is more modern" with no specific pain behind it
- "WordPress is slow," when the real issue is missing caching or a heavy theme
For many businesses, the best end state isn't "no WordPress." It's WordPress handling content and Laravel handling the application. If that sounds like your setup, this breakdown of a WordPress and Laravel hybrid stack covers when each tool earns its place.
Pick the First Feature to Strangle
The first feature sets the tone for the whole migration, so choose one that can win cleanly. Look for:
-
A clear URL boundary, like
/booking/*or/account/* - Little shared state with the rest of the site
- Real pain today, so the move delivers visible value
- Measurable success, such as fewer failed bookings or faster page loads
- Low SEO weight, because app pages rarely carry your best rankings
A Quick Scoring Checklist
Rate each candidate from 1 to 3 on four points: how clean its URL boundary is, how painful it is today, how tightly its data ties into WordPress, and how much traffic it would put at risk. Pick the feature with a clean boundary and real pain but low data coupling and low traffic risk. Save the checkout or the homepage for later.
The Example Used in This Post
We'll move a fictional appointment booking feature, currently run by a plugin at /booking, into Laravel. Logged-in WordPress users can book. Bookings are stored as a custom post type with a pile of post meta. It's a classic candidate: clear URL, real pain, contained data.
The Architecture: One Domain, Two Apps
Here's the shape of the setup:
Browser
│
▼
Nginx (example.com)
├── /booking, /booking/* → Laravel (/var/www/laravel/public/index.php)
├── /build/* → Laravel static assets
└── everything else → WordPress (/var/www/wordpress/index.php)
Both apps live on the same server in separate folders, and each gets its own PHP-FPM pool. Separate pools mean a runaway WordPress plugin can't starve Laravel of workers, and you can tune memory limits per app.
Why route in Nginx instead of inside WordPress? It's faster because WordPress never boots for Laravel requests, it keeps the two codebases independent, and rolling back means editing one block of config.
Other options exist. A subdomain like app.example.com is simpler to set up, but it splits cookies and puts the feature on a separate host in Google's eyes. A load balancer or Cloudflare Workers can do path routing too, which helps if the apps live on different servers. For a single-server site, Nginx is the shortest path.
Step 1: Route One Path to Laravel With Nginx
This is the heart of the pattern. WordPress stays the default, and Laravel claims specific paths.
server {
listen 443 ssl http2;
server_name example.com;
root /var/www/wordpress;
index index.php;
# Laravel owns /booking and everything below it
location = /booking {
try_files /nonexistent @laravel;
}
location ^~ /booking/ {
try_files /nonexistent @laravel;
}
# Laravel's Vite build output
location ^~ /build/ {
root /var/www/laravel/public;
expires 1y;
access_log off;
}
location @laravel {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /var/www/laravel/public/index.php;
fastcgi_param SCRIPT_NAME /index.php;
fastcgi_pass unix:/run/php/php8.3-fpm-laravel.sock;
}
# WordPress handles everything else
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm-wordpress.sock;
}
}
A few details matter here:
-
^~wins over regex locations. Without it, a request like/booking/confirm.phpcould fall through to the WordPress PHP block. -
Two booking locations.
= /bookingcatches the bare path, and^~ /booking/catches everything under it. A plain^~ /bookingprefix would also catch/bookings-guide, a WordPress blog post you probably want to keep. -
SCRIPT_NAME /index.phptells Laravel it's running at the domain root, so it reads the full/booking/...path from the request URI and routing works as expected.
On the Laravel side, group the routes under the same prefix:
// routes/web.php
use App\Http\Controllers\BookingController;
Route::prefix('booking')->name('booking.')->group(function () {
Route::get('/', [BookingController::class, 'index'])->name('index');
Route::get('/{slot}', [BookingController::class, 'show'])->name('show');
Route::post('/', [BookingController::class, 'store'])
->middleware('wp.auth')
->name('store');
});
Set APP_URL=https://example.com in .env so generated links point to the shared domain. Then test both sides:
curl -sI https://example.com/booking | grep -i -E "^(HTTP|x-powered-by|set-cookie)"
curl -sI https://example.com/blog/ | grep -i -E "^(HTTP|link)"
A laravel_session cookie on the first response and a WordPress Link: <.../wp-json/> header on the second confirm each request reached the right app.
Step 2: Share the Login Between WordPress and Laravel
Users who logged in on WordPress shouldn't have to log in again on /booking. There are three ways to handle this.
Option A: Laravel Validates the WordPress Cookie Itself
WordPress signs its wordpress_logged_in_{hash} cookie using the keys and salts in wp-config.php. Laravel can reproduce that check by porting the validation logic and reading the session tokens from user meta. It works, but you end up maintaining a copy of WordPress internals, and any core change to cookie handling becomes your bug.
Option B: WordPress Stays the Identity Provider
Laravel forwards the cookie value to a small, private WordPress endpoint, and WordPress answers "yes, this is user 42" using its own wp_validate_auth_cookie() function. This is the safest choice for a first migration, because WordPress keeps doing the security work it already does well.
Option C: Move Authentication to Laravel Later
Once most features live in Laravel, flip the relationship. Laravel becomes the login system, and WordPress trusts it through single sign-on. That's a later stage, not a first step.
Here's Option B. First, a must-use plugin on the WordPress side:
<?php
// wp-content/mu-plugins/laravel-auth-bridge.php
add_action('rest_api_init', function () {
register_rest_route('bridge/v1', '/whoami', [
'methods' => 'POST',
'permission_callback' => function (WP_REST_Request $request) {
$secret = defined('LARAVEL_BRIDGE_SECRET') ? LARAVEL_BRIDGE_SECRET : '';
return $secret !== ''
&& hash_equals($secret, (string) $request->get_header('x-bridge-secret'));
},
'callback' => function (WP_REST_Request $request) {
$userId = wp_validate_auth_cookie((string) $request->get_param('cookie'), 'logged_in');
if (! $userId) {
return ['authenticated' => false];
}
$user = get_userdata($userId);
return [
'authenticated' => true,
'id' => $user->ID,
'email' => $user->user_email,
'name' => $user->display_name,
'roles' => $user->roles,
];
},
]);
});
Define LARAVEL_BRIDGE_SECRET in wp-config.php with a long random value, and block /wp-json/bridge/ from the public internet at Nginx if both apps share a server.
Next, Laravel needs to read the WordPress cookie. By default, Laravel's EncryptCookies middleware tries to decrypt every incoming cookie and blanks out any it didn't create, so exclude the WordPress one. The suffix is an MD5 hash of your WordPress site URL, which you can generate with php -r "echo md5('https://example.com');".
// bootstrap/app.php (Laravel 11 and later)
->withMiddleware(function (Middleware $middleware) {
$middleware->encryptCookies(except: [
'wordpress_logged_in_c984d06aafbecf6bc55569f964148ea3',
]);
$middleware->alias(['wp.auth' => \App\Http\Middleware\WordPressAuth::class]);
})
Then the middleware:
<?php
// app/Http/Middleware/WordPressAuth.php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Symfony\Component\HttpFoundation\Response;
class WordPressAuth
{
public function handle(Request $request, Closure $next): Response
{
$cookie = $request->cookie(config('services.wordpress.cookie_name'));
if (! $cookie) {
return $this->toLogin($request);
}
$wpUser = Cache::remember('wp-user:'.hash('sha256', $cookie), 60, function () use ($cookie) {
$response = Http::withHeaders([
'X-Bridge-Secret' => config('services.wordpress.bridge_secret'),
])
->timeout(3)
->post(config('services.wordpress.url').'/wp-json/bridge/v1/whoami', [
'cookie' => $cookie,
]);
return $response->ok() && $response->json('authenticated')
? $response->json()
: null;
});
if (! $wpUser) {
return $this->toLogin($request);
}
$request->attributes->set('wp_user', $wpUser);
return $next($request);
}
private function toLogin(Request $request): Response
{
return redirect()->away(
config('services.wordpress.url').'/wp-login.php?redirect_to='.urlencode($request->fullUrl())
);
}
}
The 60-second cache keeps WordPress from answering on every request. The trade-off is that a user who logs out may keep access to /booking for up to a minute. Shorten the cache if that matters for your feature.
Step 3: Handle Data Without a Big-Bang Database Move
You don't need to migrate the whole WordPress database. Laravel only needs two things: read access to the WordPress data it depends on, and its own tables for the feature it now owns.
Add a second connection in config/database.php:
'wordpress' => [
'driver' => 'mysql',
'host' => env('WP_DB_HOST', '127.0.0.1'),
'port' => env('WP_DB_PORT', '3306'),
'database' => env('WP_DB_DATABASE'),
'username' => env('WP_DB_USERNAME'),
'password' => env('WP_DB_PASSWORD'),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => 'wp_',
'strict' => false,
],
Give that connection a MySQL user with SELECT only. If Laravel can't write to WordPress tables, a bug in your new code can't corrupt the old site.
CREATE USER 'laravel_ro'@'localhost' IDENTIFIED BY 'a-long-random-password';
GRANT SELECT ON wordpress_db.* TO 'laravel_ro'@'localhost';
For richer reads, Corcel, an Eloquent layer for WordPress, gives you models for posts, users, terms and meta. For one feature, the query builder is often enough.
New booking data lives in Laravel's own tables and migrations, with a wp_post_id column so you can trace every record back to the plugin. Then import the old records with a command you can run as often as you like:
<?php
// app/Console/Commands/ImportWpBookings.php
namespace App\Console\Commands;
use App\Models\Booking;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class ImportWpBookings extends Command
{
protected $signature = 'booking:import-wp';
protected $description = 'Copy bookings from the WordPress plugin into Laravel';
public function handle(): int
{
$wp = DB::connection('wordpress');
$wp->table('posts')
->where('post_type', 'appointment') // use your plugin's post type
->chunkById(500, function ($posts) use ($wp) {
foreach ($posts as $post) {
$meta = $wp->table('postmeta')
->where('post_id', $post->ID)
->pluck('meta_value', 'meta_key');
Booking::updateOrCreate(
['wp_post_id' => $post->ID],
[
'customer_email' => $meta['_customer_email'] ?? null,
'starts_at' => $meta['_start_time'] ?? null,
'status' => $post->post_status,
]
);
}
}, 'ID');
$this->info('Import finished.');
return self::SUCCESS;
}
}
Because it uses updateOrCreate keyed on wp_post_id, running it twice does no harm. Run it during testing, again right before cutover, and once more just after to catch any bookings made in between. The meta keys here are placeholders, so check your plugin's actual keys in wp_postmeta first.
If WordPress still needs to know about bookings (for an admin widget, say), push events the other way with a Laravel listener that calls a WordPress endpoint, rather than writing to its tables directly.
Step 4: Keep the Look and the SEO Intact
Visitors shouldn't notice they've crossed into a different app.
- Match the header and footer. Build a Blade layout that mirrors the WordPress theme's markup and load the same compiled CSS. If the menu changes often, expose it through a small cached WordPress endpoint, because the built-in menus endpoint requires authentication.
-
Keep URLs identical. The whole point of path routing is that
/bookingstays/booking, so no redirects are needed. - Carry over the SEO basics. Use the same title pattern, a self-referencing canonical tag, Open Graph tags and any schema the plugin output. Add the Laravel URLs to your XML sitemap, or merge them into the WordPress sitemap through a filter.
- Check the edges. Look for mixed content warnings, consistent trailing slash behavior and a branded Laravel 404 page.
Path-based routing protects your structure, but it's still worth reviewing site architecture for SEO as more sections move, so internal linking stays healthy across both apps.
Step 5: Cut Over, Monitor, and Keep a Rollback Ready
A safe release looks like this:
- Deploy Laravel and run the import.
- Test everything on a staging hostname that uses the same Nginx config.
- Run the import again, then add the
/bookinglocations to production Nginx. - Check the config with
nginx -t, then reload withsystemctl reload nginx.
Rollback is the same steps in reverse. Comment out the two booking locations, test and reload, and WordPress serves /booking again within seconds. Keep the old plugin installed but unused for a couple of weeks so this option stays real. Rehearse it on staging once, so you know it works before you need it.
After the switch, watch both apps' error logs, booking completion rates, response times on the new routes and Core Web Vitals for /booking in Search Console. If you want a gentler launch, Nginx's split_clients module or a cookie flag can send only a share of visitors to Laravel at first.
Repeat Until WordPress Is Small, or Gone
Once bookings are stable, pick the next feature and run the same loop: choose, route, share, cut over, delete the plugin. That last step matters. Every plugin you remove makes the remaining WordPress install lighter, safer and easier to update.
Where you stop is up to you. Many teams settle on WordPress as a headless or traditional content system with Laravel running everything else. Others keep going until the final blog template moves and WordPress is retired.
Common Mistakes to Avoid
- Moving too much in the first cut. One feature, one boundary.
- Writing to WordPress tables from Laravel. Use read-only access and events instead.
- Forgetting background jobs. The old plugin may have sent reminder emails through WP-Cron. Recreate them with Laravel's scheduler before you disable it.
- Mismatched session lifetimes, which log people out on one side but not the other.
- Never testing the rollback. An untested rollback is just a hope.
FAQs
What is the strangler fig pattern in software development?
It's a migration approach where a new system gradually replaces a legacy one by taking over individual features behind a shared entry point. Old and new run side by side until the legacy system has nothing left to do and can be retired.
Can WordPress and Laravel run on the same domain?
Yes. A web server like Nginx can route specific paths, such as /booking, to Laravel and send everything else to WordPress. Each app runs in its own folder and PHP-FPM pool, and visitors see one seamless site.
Can Laravel use the WordPress database?
Yes. Add a second database connection pointing at the WordPress database and query it with the query builder or a package like Corcel. Use a read-only MySQL user so Laravel can't accidentally damage WordPress data, and keep new data in Laravel's own tables.
How do I share a login between WordPress and Laravel?
The safest starting point is letting WordPress stay the identity provider. Laravel forwards the WordPress login cookie to a private endpoint that validates it with wp_validate_auth_cookie() and returns the user. Later, you can make Laravel the main login and use single sign-on.
When should I use the strangler pattern instead of a full rewrite?
Use it when the site earns money or traffic every day, when features have clear URL boundaries, or when you can't afford a long code freeze. A full rewrite can make sense for small sites with little traffic or when the old system is beyond repair.
How long does a WordPress to Laravel migration take?
It depends on the feature, not the whole site. A contained feature like bookings can often move in a few weeks, including testing. A full migration is a series of these moves spread over months, with value delivered after each one.
Wrapping Up
The strangler pattern turns a scary rewrite into a series of small, reversible moves. Route one path, share the login, give Laravel its own data, keep the URLs and SEO intact, and keep a rollback one reload away. Then repeat.
If you'd rather have an experienced hand plan or build the move, Khurram Shahzad offers custom Laravel application development, including legacy WordPress modernization.
For a deeper look at the pattern itself, Microsoft's Strangler Fig pattern reference covers the trade-offs at an architecture level.
Top comments (0)