MIGRATION

CodeIgniter to Laravel: cost, timeline, and the two things that always break

The method is boring on purpose: one router, both frameworks live, and the two things that break every single time dealt with first.

The framework differences are not the hard part of this migration. CI3 and Laravel are both MVC PHP, and a competent developer can read either in an afternoon. The hard part is doing it without stopping the business, and the failures land in the same two places every time.

This is the method we use and the order we use it in. Where it helps, the running example is the common shape: a mid-size booking platform on CodeIgniter 3 and an unsupported PHP, hand-rolled ORM, no tests.

Do not rewrite it

The instinct is to start a clean Laravel app and port features across. Every time I have watched that attempted, one of two things happened. Either the rewrite ran twice as long as planned while the old system kept accruing changes, or it shipped and immediately lost behaviour nobody had documented.

Both failures have the same cause. A working application contains years of decisions nobody wrote down: the discount that only applies on renewals, the export a single large customer depends on, the validation rule added after an incident in 2019. None of that is in the specification, because there is no specification. It is in the code, and the only way to preserve it is to move the code rather than reinterpret it.

There is also a scheduling trap. A rewrite has to catch a moving target. While you build the replacement, the business keeps asking for changes to the original, so either you freeze the old system, which nobody will agree to, or you implement every change twice. On a nine-month rewrite that second tax alone can add a third to the budget.

What works is the strangler pattern. Put one router in front, migrate module by module, and keep both frameworks live until the last route moves. Expect the two to run side by side for most of the project rather than a token overlap at the end.

It feels slower. It is not. It moves the risk out of one terrifying cutover day into thirty small reversible steps, each of which can be deployed on a Tuesday afternoon and rolled back in a minute.

The setup: one router, two apps

Laravel becomes the front door. Every request hits it first. Anything not yet migrated falls through to CodeIgniter.

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

location @legacy {
    fastcgi_pass  php-fpm;
    fastcgi_param SCRIPT_FILENAME /var/www/legacy/index.php;
}

The catch-all route

In Laravel, a single route at the very bottom of the file hands anything unmatched to the legacy application. As each module moves, its routes get defined above the catch-all and stop falling through. That is the whole mechanism, and it is the reason this approach is reversible: deleting a route sends that URL straight back to CodeIgniter.

// routes/web.php, last line in the file
Route::any('{any}', LegacyProxyController::class)
    ->where('any', '.*');

The proxy can be an internal nginx redirect to the legacy location, or a small controller that forwards the request over HTTP to the legacy app and streams the response back. The nginx route is faster; the controller version is easier to instrument, which matters early on when you want to see exactly which URLs are still legacy. We usually start with the controller and switch once the traffic picture is clear.

Either way, log every fall-through with its URL. That log becomes your migration backlog, ordered by real traffic rather than by whoever shouts loudest. It routinely shows that most legacy traffic hits a handful of routes, which is worth knowing before you plan the order.

One database, two apps

Both applications share one database. That is essential, and it is what makes the gradual route possible at all. There is no synchronisation, no dual write, no eventual consistency problem, because there is only ever one copy of the truth.

The consequence is that Laravel’s migrations must be additive during the migration period. You add columns and tables; you do not rename or drop anything the legacy application still reads. Renames get deferred to a cleanup phase after the last route moves, and that cleanup is a real line in the plan rather than something you hope to get to.

Thing that breaks #1: sessions

This one catches everybody. A user logs in on a CI3 page, clicks through to a migrated Laravel page, and gets logged out. They log in again and get logged out of the legacy side. Users report it as “the site keeps signing me out”, and it is the single fastest way to lose confidence in a migration.

CodeIgniter and Laravel serialise session data differently, sign cookies differently, and disagree about how a session id is stored. CI3 uses PHP’s native serialisation with its own handler; Laravel serialises its own payload and signs the cookie with the application key. They will not read each other’s sessions however you configure them.

There are two ways through and only one is any good.

The bad way: keep two sessions and sync them with a shared token. It works in testing and produces bizarre edge cases in production, almost always around logout, session expiry, and the user who has two tabs open. Those states are miserable to reproduce, because they only occur for real users.

The way that works: move both applications onto one session store and make one of them the authority. Sessions go in Redis, a small CI3 session driver reads and writes Laravel’s format, and Laravel owns authentication from day one. Legacy pages read the session. They no longer create it.

Practically that means:

  • Both apps point at the same Redis instance and the same key prefix.
  • Laravel’s SESSION_DRIVER is redis, and its cookie domain covers both apps.
  • The CI3 driver decodes Laravel’s payload on read and re-encodes on write. It is perhaps 150 lines and most of it is serialisation.
  • Login, logout, registration and password reset all move to Laravel in week one, before anything else. The legacy login form posts to a Laravel endpoint.

Budget a week for this and do it first, before migrating a single feature. If sessions are not solved, nothing else can be tested properly, because every test that involves a logged-in user crossing the boundary is meaningless.

Thing that breaks #2: the query builder

CI3’s Active Record is not Eloquent, and the differences are quiet rather than loud. Loud differences get caught in code review. Quiet ones reach production.

The ones that have bitten us:

CI3 behaviourWhat happens on port
where() chains with an implicit ANDPorted to orWhere by mistake, silently widening a result set
where_in() on an empty arrayProduces WHERE x IN (), a MySQL syntax error some CI versions swallowed
get() returns a result object with num_rows()Eloquent returns a Collection; count() on an empty result behaves differently in conditionals
Interpolated variables inside a where stringIs where the SQL injection you do not know about is living
$this->db->last_query() used for loggingNo direct equivalent, and debugging habits quietly break
Implicit NULL handling in where('x', null)CI3 writes x IS NULL; a naive port can write x = NULL, which matches nothing

The dangerous part is not the syntax. It is that some queries produce slightly different results, and slightly different results in a money path do not throw an error. They just make the numbers wrong, quietly, until somebody reconciles a month-end and finds a gap.

The regression suite comes before the migration

Which is why the tests come first. On every one of these the first fortnight goes on characterisation tests around the paths that touch money, using the existing system as the source of truth. We are not testing whether the old behaviour is correct. We are recording what it is, so that a rewritten query that drifts gets caught the same day.

The shape that works:

  1. Pick the money paths. Checkout, invoicing, refunds, commission, anything producing a number a customer or an accountant will read.
  2. Capture real inputs. Anonymised production rows, including the ugly ones. Synthetic fixtures pass tests that production data fails.
  3. Assert on outputs, not implementation. The test should not know whether CI3 or Laravel produced the answer, because it will need to pass under both.
  4. Run them against the legacy app first, and fix the tests until they are green. Anything red at this stage is a bug you have just found for free.

This fortnight reliably finds pre-existing bugs, because nobody has ever asserted what these paths return. A mispricing that has been quietly running for a year is a common find, and one of them usually pays for the phase on its own.

The order I move things in

  1. Sessions and authentication. Week one. Nothing else works until this does, and every later phase depends on being able to test a logged-in user.
  2. Read-only pages with no writes. Static-ish content, reports, listings. Low risk, and they prove the routing, the layout and the deploy pipeline hold under real traffic before anything can be damaged.
  3. Forms that write to one table. Still low risk, and they exercise validation, CSRF and the session under real load. This is where you find out whether your CSRF strategy works across both apps.
  4. The money paths. Checkout, billing, anything moving an amount. These go last among the features, and only with the regression suite green.
  5. Admin and back office. Usually the largest surface and the lowest risk, because the users are internal and will tell you immediately when something is wrong. Doing this earlier wastes the low risk on something you could have spent on learning.
  6. Cron and queue work. After the web routes, not before. Easier to run in parallel and compare outputs for a week before switching over.

Two more things that bite

Not in the same league as sessions and queries, but both have cost us days.

File paths and uploads. CI3 applications usually write uploads to a path relative to the front controller and store that path in the database as a string. Laravel expects a filesystem disk. Until every route has moved, both apps have to resolve the same file from the same place, which means a shared disk configuration and a decision about whether stored paths get rewritten now or at cleanup. Rewriting them at cleanup is usually right, and stored paths are the sort of thing that gets forgotten until an image disappears.

Helpers, libraries and config. A mature CI3 app has a folder of helper functions loaded globally and a config array read from everywhere. Some of those helpers encode genuine business rules. Port them as real classes with tests rather than copying them into a helpers.php file Laravel autoloads, or you will have moved the mess rather than removed it. Where a helper is used in fewer than five places, inline it and delete it.

What it actually costs

A mid-size application with two engineers on it runs to roughly fourteen weeks. Rough shape of that, and yours will differ:

PhaseTime
Audit and regression suite2 weeks
Sessions, auth, routing spine1 week
Module migration8 weeks
Money paths and reconciliation2 weeks
Cutover, monitoring, legacy removal1 week

At our published CodeIgniter rates, $22 an hour at mid level, $30 at senior and $38 at lead, a system of that size lands between $25,000 and $45,000. A very small CI3 app can be done in six weeks. A large one with heavy integrations runs past twenty. The variables that move the number most, in order:

  • How much of the logic is in the database. Stored procedures and triggers are the single biggest multiplier on the estimate.
  • How many integrations point at your URLs. Payment webhooks, ERP callbacks and partner APIs each need a plan, and they are the most commonly forgotten item in a brief.
  • Whether anyone can explain the money paths. If yes, two weeks. If nobody is left who knows, four to six.
  • PHP version. Moving from 5.6 is a different project from moving from 7.4.

Anyone quoting a firm number without reading the code is guessing. We start every one of these with a two-day audit at a fixed $800, because nobody can price a migration of something they have not read.

Cutover and the week after

With the strangler pattern there is no cutover day in the traditional sense. The last route moves and the legacy application stops receiving traffic. What remains is a decommissioning, and it deserves a fortnight rather than an afternoon.

  • Leave the legacy app deployed but unrouted for two weeks. It costs nothing and it is the fastest rollback available.
  • Watch the fall-through log go to zero. If it does not, you missed a route, and the log tells you exactly which.
  • Then run the cleanup migrations. The renames and drops you deferred. Do them as one deliberate piece of work with the tests green.
  • Delete the legacy code from the repository, not just from the server. Otherwise somebody will read it in a year and believe it.

When not to migrate

Genuinely: often.

If the application works, earns money, blocks nothing, and nobody needs to change it much, patch it and leave it alone. CI3 can be kept secure on PHP 8 with a patch retainer for a fraction of a migration, and “the framework is unsupported” is a risk to manage rather than an emergency to spend on.

The shape that argues for it: a decade of unpatched CI3, dozens of vulnerable dependencies, FTP deploys, and nothing on the roadmap that needs the framework to change. A few weeks of security work and a PHP version bump buys years, and if the business model shifts later the migration starts from a codebase that is no longer dangerous to touch.

Migrate when you need to change the thing. Not because the framework’s name looks old on a page.

Common questions

Can we do this without any downtime?

Yes, and we have four times. The strangler pattern is specifically what makes it possible, because no single deploy moves enough of the system to require a maintenance window.

Should we go to Laravel or to something else?

Laravel, in almost every case, if you are already in PHP and the team is staying. It is the framework with the deepest hiring pool, the longest support runway and the closest conceptual fit to CI3, which keeps the port mechanical. Changing language and architecture at the same time as changing framework is how projects reach month eighteen.

What about CodeIgniter 4?

It is a real option and an underrated one. If your application is small, works, and you mainly want supported software on a modern PHP, CI4 is a shorter and cheaper move than Laravel. What it does not give you is the hiring pool or the ecosystem, and those are usually the reasons people are leaving CI3 in the first place.

How much can we do ourselves?

More than you would think. The routing spine and the session work want somebody who has done it before, because those are the two phases where a wrong decision is expensive to unwind. Module migration is ordinary work and an in-house team picks up the pattern in a fortnight. Several of our engagements are the first three weeks plus review, rather than the whole project.

Will performance improve on its own?

Partly, and it is worth being precise about why. Some of the gain comes from the framework: eager loading replaces N+1 queries almost for free. The rest comes from things that were never about the framework at all, namely indexes, caching, and deleting work that did not need to happen. Do not sign a migration off on a performance promise alone. Get the performance work priced as performance work.

Nine guides · none behind an email form Written by the engineers who did the work
Reply within one business day Get an estimate