If you run a business on a Yii application, you already know the shape of the problem. The app works. It also runs on a PHP version you'd rather not say out loud, nobody wants to touch the checkout controller, and the last two developers you interviewed had never heard of Yii. This guide is the migration I actually run for clients: a phased move to Laravel and Vue where the old app keeps serving traffic the whole way through.
It's written for the person who has to make the call and the developer who has to do the work. Skip to the part you need; each section links to a deeper post where one exists.
Who this guide is for
- You have a Yii2 application in production, or a Yii1 one (the approach is the same; some steps are harder).
- The application matters. Customers, orders, an internal operation, or a SaaS product depend on it.
- You can't afford to freeze features for a year while a new version is built in a corner.
If your app is small enough to rewrite in a couple of weeks, do that instead. This guide is for everything bigger.
Why not just rewrite it?
The big-bang rewrite fails for a predictable reason: the old app keeps changing while the new one is being built, so the new one is always chasing a moving target, and the day you switch is the day every unknown becomes an incident at once. I've watched teams spend fourteen months on a rewrite and never ship it.
The alternative is the strangler pattern: put the new application in front of the old one, route a slice of traffic to it, and grow that slice until the old app has nothing left to do. Every step is small, reversible, and in production. That's the whole guide in one sentence; the rest is detail.
Step 1: Assess what you actually have
Before writing any Laravel, inventory the Yii app. You're building the migration map that everything else follows.
Runtime and dependencies
- PHP version in production and the highest version the app can run on today.
- Yii version (
Yii::getVersion()), and whether it's a stock install or a fork with local patches. composer.json(Yii2) or theprotected/extensionsfolder (Yii1). List every extension and mark which are abandoned.- Anything the app shells out to: ImageMagick, wkhtmltopdf, custom binaries.
Structure
- Modules (
modules/), and the controllers and actions in each. For Yii2,grep -r "public function action" controllers/ modules/gets you a first list. - Routes:
urlManagerrules, pretty URLs, anything external systems call (webhooks, API endpoints, links in transactional emails). - Console commands and cron entries. These are always forgotten and always matter.
- Widgets and the frontend: how much is Gii-generated GridView/ActiveForm, how much is custom jQuery.
Data
- The schema, with foreign keys documented even if they don't exist as constraints.
- Tables with no model. Tables with three models.
- Where user identity lives, how passwords are hashed, and whether there's a
saltcolumn. This decides your auth strategy (Step 4).
Integrations
- Payments, email providers, CRMs, file storage, third-party APIs, and the credentials each needs.
Tests and docs
- Existing tests are usually few. Note what's covered. You'll be writing characterization tests (Step 8) for the rest.
The output is a spreadsheet: one row per module, with size, risk, business value, dependencies, and a rough effort. Sort it. Auth goes first regardless; after that, order by value ÷ risk. A reporting module that the CFO looks at weekly and touches nothing else is a great second module. The order-processing core with six integrations is not.
If you'd rather have someone else do this part, this is exactly what the Migration Assessment produces.
Step 2: Stand up Laravel beside Yii
Create a fresh Laravel project on the same server (or the same VPC) as the Yii app, pointed at the same database. Don't copy the data. Don't rename tables. Laravel is perfectly happy with a schema it didn't create, and every rename you do now is a rename Yii has to survive too.
Then route by path at the web server. With nginx, a map picks the document root per URL prefix:
map $uri $app_root {
default /var/www/legacy/web; # Yii
~^/(account|orders|reports)(/|$) /var/www/app/current/public; # Laravel
~^/build/ /var/www/app/current/public; # Vite assets
}
server {
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 unix:/run/php/php8.3-fpm.sock;
}
}
Migrated prefixes go to Laravel; everything else still goes to Yii. Adding a module to the migration is one line in that map. If Yii needs an older PHP than Laravel, run two PHP-FPM pools and map the socket the same way. The full setup, including sessions and deploys, is in Running Yii and Laravel side by side.
At this point Laravel serves nothing but a health check. That's fine. The routing is the hard part, and it's done.
Step 3: Decide how the two apps share a login
Users must not notice there are two applications. Two things make that true:
- One session of truth. Yii and Laravel serialize sessions differently, so don't try to share the raw session store. Instead, migrate login itself to Laravel first, and have Laravel issue a short-lived, signed cookie that the Yii app verifies on each request and uses to log its own identity in. Yii1 does this in an
onBeginRequesthandler; Yii2 in a bootstrap component. When a user logs out, both cookies are cleared. - No forced password resets. See the next step.
Doing auth first feels like starting with the scariest module. It isn't: the login screen is small, well understood, and the payoff is that every module after it is just a page.
Step 4: Bring the password hashes with you
Yii2's Security::generatePasswordHash() produces standard bcrypt ($2y$13$…), which Laravel's Hash::check() verifies with no changes. Yii1 apps are the wild west: CPasswordHelper (bcrypt), or md5($password), or sha1($salt . $password), sometimes two of those in the same table because someone upgraded the hashing halfway through.
The fix is a custom hasher that understands the legacy formats, plus Laravel's built-in rehash-on-login (hashing.rehash_on_login, enabled by default since Laravel 11). The first time each user logs in through Laravel, their hash is silently upgraded. Nobody gets an email asking them to reset anything.
public function check(#[\SensitiveParameter] $value, $hashedValue, array $options = []): bool
{
if (str_starts_with($hashedValue, '$2y$') || str_starts_with($hashedValue, '$2a$')) {
return parent::check($value, $hashedValue, $options); // Yii2 / CPasswordHelper
}
if (strlen($hashedValue) === 32) {
return hash_equals($hashedValue, md5($value)); // Yii1 tutorial-era
}
return false;
}
The full class, the salted variant, the config, and the migration that widens a 32-character password column are in Verifying Yii password hashes in Laravel.
Step 5: Models: ActiveRecord to Eloquent, on the existing schema
Eloquent's defaults assume id, created_at, updated_at, and snake_case plural table names. Your Yii schema assumes whatever it assumed. Don't fight it and don't migrate it yet; configure around it.
class Order extends Model
{
protected $table = 'tbl_order'; // Yii1 prefix
const CREATED_AT = 'create_time';
const UPDATED_AT = 'update_time';
protected $casts = ['status' => OrderStatus::class, 'placed_at' => 'datetime'];
}
Then map the concepts:
| Yii | Laravel |
|---|---|
relations() / hasMany() in a getter |
hasMany() relationship method |
scopes() / defaultScope() |
local scopes / global scopes |
TimestampBehavior |
$timestamps + CREATED_AT/UPDATED_AT |
| Custom behaviors | traits, observers, or casts |
rules() |
Form Requests (validation is a request concern) |
afterSave() / beforeDelete() |
model events / observers |
safe attributes |
$fillable |
Side-by-side code for each of those is in Yii ActiveRecord to Eloquent. Two rules that save the most pain:
- Validation moves out of the model. Yii puts
rules()on the record; Laravel validates the request. Resist the urge to recreaterules()on Eloquent models. - Both apps write to the same tables during the migration, so keep Eloquent's writes byte-compatible with Yii's expectations (same enum strings, same timestamp format) until the Yii side of that table is gone.
Step 6: Routes, controllers, and the API surface
Laravel routes are explicit. For each migrated module, write the routes to match the URLs Yii served, including the ugly ones, because external systems and emails link to them. Keep old URLs alive with redirects rather than breaking them.
Route::prefix('orders')->group(function () {
Route::get('/', [OrderController::class, 'index']);
Route::get('/view/{id}', fn ($id) => redirect("/orders/{$id}", 301)); // Yii's /orders/view?id=… shape
Route::get('/{order}', [OrderController::class, 'show']);
});
Controllers shrink: validation goes to Form Requests, authorization to Policies, response shaping to API Resources. Yii's AccessControl filter and RBAC items become Gates and Policies (a separate post covers the RBAC data migration).
If the Yii app exposes an API that mobile apps or partners use, migrate it last and keep its contract frozen. Add versioning in Laravel from day one so the next migration is easier than this one.
Step 7: The frontend: from widgets and jQuery to Vue
Gii-generated GridViews and ActiveForms are the bulk of most Yii admin areas. They don't port; they get rebuilt. The good news is that they're formulaic, so the rebuild is fast once you have a pattern.
My default for business apps is Vue 3 with Inertia.js: server-side routing and auth, Vue components for the screens, no separate API to maintain. A shared DataTable component and a Form component with server-driven validation errors replace GridView and ActiveForm across the whole app. Where the Yii app has a public marketing frontend, Blade is often enough.
Two practical notes:
- During the migration, users move between Yii pages and Laravel pages. Give both the same layout shell (header, nav, footer) so the seams are invisible. Yes, that means maintaining the nav in two places for a while. Put the nav's data in one place (a small JSON endpoint or a shared config file) if that bothers you.
- Keep Yii's static asset paths (
/assets/…) routed to Yii and Laravel's (/build/…) to Laravel. The nginx map above does this.
Step 8: Testing against the old behavior
You will not get a specification for the Yii app. The Yii app is the specification. So test against it:
- Characterization tests. For each module, capture what the Yii version does for a set of inputs (HTTP responses, database rows written, emails sent), then assert the Laravel version does the same. Pest and Laravel's HTTP testing make the Laravel side quick; a small script with the Yii app's test database covers the other side.
- Shadow reads. For reporting modules, run both versions against production data for a week and diff the output before switching the route.
- Feature flags at the router. Because routing happens in nginx, a rollback is editing one line and reloading. Keep that ability until the module has been live for a while.
Step 9: Console commands, cron, and queues
Yii console commands map to Artisan commands almost one to one. The improvement is what runs them: replace the crontab full of php yiic … lines with a single schedule:run entry and Laravel's scheduler, and move anything slow or unreliable (email sends, PDF generation, third-party syncs) onto queues with retries.
Migrate cron jobs when their module migrates, not before, so a job never runs against both apps' assumptions at once.
Step 10: Cutover, one module at a time
There is no cutover weekend. Each module's cutover is:
- Laravel version deployed, hidden behind a route prefix nobody uses yet.
- Characterization tests green; shadow run clean if applicable.
- nginx map updated; reload. The module is live on Laravel.
- Watch errors and timings for a few days.
- Delete the Yii controller, views, and models for that module. Actually delete them. Dead Yii code is how the next developer ends up editing the wrong file.
Repeat until the default line in the nginx map points at nothing. Then remove Yii, its PHP-FPM pool, and its cron entries, and archive the repository.
Step 11: What to do after Yii is gone
Now do the things that weren't safe while two apps shared the schema:
- Rename tables and columns to Laravel conventions; drop the
tbl_prefix. - Add the foreign key constraints that were only ever documented.
- Upgrade PHP and Laravel to current versions (you're on a supported stack; this is now routine).
- Set up zero-downtime deploys with Forge or your CI, if you haven't already.
How long does this take?
It depends on the module count and how much of the app is custom versus generated. Small apps: a couple of months. A large platform with integrations: longer, but the first migrated module is usually in production within the first three or four weeks, and that's the point. You are never a year from seeing results. Cost drivers are covered on the migration service page.
When to get help
Do this yourself if you have a developer who knows Laravel well, time to learn the Yii app's quirks, and no hard deadline. Get help if any of these is true:
- The person who understood the Yii codebase is gone.
- The app is on PHP 7.x or older and hosting is pushing you to upgrade.
- You need a fixed price and a date before you can get budget approved.
- The first attempt at a rewrite already failed.
The honest version: sometimes the right answer is to stabilize the Yii app for another year and migrate later, and I'll tell you that on a call if it's true.