Running Yii and Laravel side by side on one domain

The setup that makes a phased Yii to Laravel migration possible: nginx routing by path, one database, a signed cookie that keeps users logged in across both apps, and a deploy layout that doesn't fight itself.

The strangler pattern only works if the plumbing is boring. This post is the plumbing: how to serve a legacy Yii application and a new Laravel application from the same domain, against the same database, with users who never notice there are two of anything. It's the setup I put in place in the first week of every Yii to Laravel migration, before a single module moves.

Everything here applies to Yii1 and Yii2. Where they differ, I've marked it.

The shape of it

browser ──► nginx ──┬── /account, /orders, /reports ──► Laravel (PHP 8.3)
                    └── everything else ─────────────► Yii     (PHP 7.4 or 8.x)
                                        both ──► the same MySQL database

Three decisions make this work:

  1. Route at the web server, by path. Not in PHP, not with a proxy in front of a proxy. nginx already knows how to do this.
  2. One database, no copies. Both apps read and write the same tables until the Yii side of each table is retired.
  3. Move login first, and bridge the session with a signed cookie instead of trying to share PHP session data between two frameworks.

Filesystem layout

Keep the two apps as siblings, and give Laravel the release-symlink layout Forge and most CI deploys expect:

/var/www/legacy/            # the Yii app, exactly where it was
/var/www/legacy/web/        # Yii2 web root (Yii1: /var/www/legacy/ with index.php at root)
/var/www/app/releases/…     # Laravel releases
/var/www/app/current  ->    # symlink to the live release
/var/www/app/shared/.env    # env, storage/, symlinked into each release

Don't move the Yii app to make room. Every path in it is load-bearing somewhere: cron, backups, a hardcoded upload directory.

nginx: route by prefix

nginx's root directive accepts a variable, so a map on the request URI can pick the document root per prefix. Adding a migrated module is one line.

# Which application serves which prefix.
map $uri $app_root {
    default                                   /var/www/legacy/web;
    ~^/(account|orders|reports)(/|$)          /var/www/app/current/public;
    ~^/build/                                 /var/www/app/current/public;   # Vite assets
    ~^/(login|logout|password)(/|$)           /var/www/app/current/public;   # auth moved first
}

# Which PHP-FPM pool, if Yii needs an older PHP than Laravel.
map $uri $php_sock {
    default                                   unix:/run/php/php7.4-fpm.sock;
    ~^/(account|orders|reports|login|logout|password)(/|$)  unix:/run/php/php8.3-fpm.sock;
}

server {
    listen 443 ssl http2;
    server_name app.example.com;

    root  $app_root;
    index index.php;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_pass $php_sock;
    }

    location ~ /\.(?!well-known) { deny all; }
}

Notes from doing this more than once:

  • Keep both maps in sync. Better: generate this file from one list. A ten-line script that writes the two map blocks from a migrated.txt is worth it by the third module.
  • try_files … /index.php?$query_string works for both frameworks (Yii uses $args; they're the same variable).
  • Static assets. Yii publishes to /assets/…, Laravel's Vite build lives under /build/…. Because $app_root is decided per request, each app's assets resolve against the right root automatically as long as the prefixes are in the map.
  • Uploads. If users upload files to a directory inside the Yii app, point Laravel's filesystems.php at that same directory (or an S3 bucket both apps use). Do not let the two apps store uploads in two places.
  • Testing a module before it's live. Add a second server block on an internal hostname whose map sends everything to Laravel. You can exercise a migrated module end to end without changing the public routing.

If the site sits behind a load balancer or Cloudflare, nothing changes; the routing decision stays on the origin.

One database, two ORMs

Both apps talk to the same MySQL database with the same credentials (or two users with the same grants; either is fine). The rules that keep this safe:

  • No renames during the migration. Eloquent can be told about tbl_order, create_time, and a primary key called order_id. Do that instead of renaming; every rename is a change Yii has to survive too.
  • Schema changes are additive. New columns, new tables, new indexes: fine. Dropping or changing a column: only after the last Yii code that touches it is gone.
  • One migration tool. Use Laravel migrations for new changes from day one, and stop using Yii's. Two migration tables against one schema ends badly.
  • Write-compatible values. If Yii stores status = 'shipped' and reads it with a string compare, Laravel's enum cast must write exactly 'shipped'. Check date formats and timezone handling the same way; Yii1 apps in particular often store local time.
  • Sequences and IDs. Auto-increment is owned by MySQL, so two apps inserting into the same table is not a problem.

Sharing the login

Yii and Laravel both use PHP sessions, but the data inside is serialized differently and keyed differently, so sharing the raw session store is a trap. The reliable approach is an auth bridge:

  1. Login, logout, and password reset move to Laravel first.
  2. When Laravel logs a user in, it also sets a second cookie, legacy_auth, containing the user id, an expiry, and an HMAC signature.
  3. On every request, Yii checks that cookie. If the signature is valid and it isn't expired, Yii logs its own identity in for the request without touching the password.
  4. Logout in Laravel clears both cookies. Yii's own logout action redirects to Laravel's.

Laravel side

// app/Listeners/IssueLegacyAuthCookie.php — listen for Illuminate\Auth\Events\Login
public function handle(Login $event): void
{
    $payload = $event->user->getAuthIdentifier().'|'.(time() + 7200);
    $sig = hash_hmac('sha256', $payload, config('legacy.bridge_secret'));

    Cookie::queue(cookie(
        name: 'legacy_auth',
        value: $payload.'|'.$sig,
        minutes: 120,
        path: '/',
        secure: true,
        httpOnly: true,
        sameSite: 'lax',
    ));
}

On Logout, queue Cookie::forget('legacy_auth').

Yii2 side

// config/web.php
'bootstrap' => ['legacyAuth'],
'components' => [
    'legacyAuth' => ['class' => app\components\LegacyAuthBridge::class],
],
namespace app\components;

use Yii;
use yii\base\BootstrapInterface;

class LegacyAuthBridge implements BootstrapInterface
{
    public function bootstrap($app)
    {
        $app->on(\yii\web\Application::EVENT_BEFORE_REQUEST, function () use ($app) {
            $raw = $_COOKIE['legacy_auth'] ?? null;
            if (!$raw || !$app->user->isGuest) {
                return;
            }
            [$id, $exp, $sig] = array_pad(explode('|', $raw), 3, null);
            $expected = hash_hmac('sha256', "$id|$exp", $app->params['bridgeSecret']);
            if ($exp > time() && hash_equals($expected, (string) $sig)) {
                $identity = \app\models\User::findIdentity($id);
                if ($identity) {
                    $app->user->login($identity, 0);
                }
            }
        });
    }
}

Yii1 side

Same logic in onBeginRequest in protected/config/main.php:

'onBeginRequest' => function ($event) {
    $raw = $_COOKIE['legacy_auth'] ?? null;
    if (!$raw || !Yii::app()->user->isGuest) return;
    [$id, $exp, $sig] = array_pad(explode('|', $raw), 3, null);
    $expected = hash_hmac('sha256', "$id|$exp", Yii::app()->params['bridgeSecret']);
    if ($exp > time() && hash_equals($expected, (string) $sig)) {
        $identity = new LegacyIdentity($id);   // a CUserIdentity subclass that skips the password check
        Yii::app()->user->login($identity);
    }
},

Points that matter:

  • The cookie is HttpOnly, Secure, SameSite=Lax, and signed. It carries no password, no session data, nothing a user could tamper with usefully.
  • The secret lives in both apps' config, out of version control.
  • Two hours is a reasonable expiry because Laravel refreshes the cookie on activity through a small middleware. Pick what your security posture allows.
  • If the app has "remember me", keep it in Laravel only. The bridge cookie is the only thing Yii needs.

The other half of "users never notice" is that nobody is asked to reset a password. That's handled by verifying Yii's hashes in Laravel and rehashing on login, covered in Verifying Yii password hashes in Laravel.

Deploys

  • Laravel deploys with releases and a current symlink (Forge's zero-downtime deploy, Deployer, or a short script). Because nginx resolves /var/www/app/current/public at request time, a deploy is atomic from nginx's point of view.
  • Yii keeps whatever deploy it has. Don't modernize it; you're deleting it.
  • PHP-FPM: two pools while the PHP versions differ. When Yii is gone, remove the old pool and the second map.
  • Cron: one * * * * * php /var/www/app/current/artisan schedule:run line, plus the Yii cron entries for modules that haven't moved yet. Delete each Yii entry when its module moves.
  • Queues: a Horizon or queue:work supervisor for Laravel from the start, so migrated modules can push slow work off the request immediately.

Rolling back a module

This is the reason for routing at nginx. If a migrated module misbehaves:

  1. Remove its prefix from both maps.
  2. nginx -t && systemctl reload nginx.

Traffic is back on Yii in seconds, with no deploy. Keep the Yii code for a module until it has been live on Laravel for a couple of weeks, then delete it.

Where this gets harder

  • Yii apps that generate URLs for the migrated modules. Yii's createUrl() will keep producing Yii-shaped URLs (/order/view?id=42). Either add Laravel routes that accept those shapes and redirect, or patch the Yii URL rules to emit the new paths. Redirects are less work and keep old emails working.
  • Shared layouts. Users move between Yii pages and Laravel pages. Give both the same header and navigation, and keep the nav's contents in one place both apps read.
  • CSRF. Each app validates its own tokens; forms post to the app that rendered them. The only cross-app POST is usually logout. Make it a link to Laravel's logout with its own CSRF handling, or a GET that Laravel accepts specifically for this purpose.

When to get help

If your Yii app is on a PHP version the host is about to remove, or the person who set up the server is gone, this is where I'd start: get the routing and the auth bridge in place first, because once they exist the migration turns into a list of ordinary Laravel work. That first week is exactly what the Migration Assessment scopes.

Kelly Brintle

Principal Architect at CraftWeb. I move legacy PHP applications to Laravel and Vue and build platforms for businesses that run on them. More about me