DEV Community

Cover image for Strangler Pattern: Moving One WordPress Feature to Laravel at a Time
Jay Barker
Jay Barker

Posted on

Strangler Pattern: Moving One WordPress Feature to Laravel at a Time

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)
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

A few details matter here:

  • ^~ wins over regex locations. Without it, a request like /booking/confirm.php could fall through to the WordPress PHP block.
  • Two booking locations. = /booking catches the bare path, and ^~ /booking/ catches everything under it. A plain ^~ /booking prefix would also catch /bookings-guide, a WordPress blog post you probably want to keep.
  • SCRIPT_NAME /index.php tells 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');
});
Enter fullscreen mode Exit fullscreen mode

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)"
Enter fullscreen mode Exit fullscreen mode

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,
            ];
        },
    ]);
});
Enter fullscreen mode Exit fullscreen mode

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]);
})
Enter fullscreen mode Exit fullscreen mode

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())
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

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,
],
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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 /booking stays /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:

  1. Deploy Laravel and run the import.
  2. Test everything on a staging hostname that uses the same Nginx config.
  3. Run the import again, then add the /booking locations to production Nginx.
  4. Check the config with nginx -t, then reload with systemctl 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)