Case study · two framework versions, three PHP versions, no Composer, no tests

CodeIgniter 1.7 to CodeIgniter 3, PHP 5 to 8.4: what it actually took

26 October 2026 · keepPhpUp · A 2009 open-source invoicing application, migrated end to end with the same process as our Laravel case study

BambooInvoice is a self-hosted invoicing application whose last commit is from November 2009: the framework committed into the repository under its own name, no dependency manager, no tests, passwords stored with an encryption that needs an extension PHP removed, and a PDF engine that cannot run on anything current. It is the shape of the legacy CodeIgniter application we are asked about, so we moved it before quoting that work to anyone.

This is a public open-source application, not a client's. The repository has a tag before the work and a tag after it, one commit per stage with its written spec, the saved output of every verification run, and a time log. Nothing below is an estimate.

The application

Clients, contacts, invoices with line items and tax, payments, PDF, email, reports, exports. About 7,000 lines of application PHP across 14 controllers, 7 models and 50 views, written for PHP 5.2 and CodeIgniter 1.7.2. No assessment existed, so the first thing we wrote was the architecture, from the code, before anything moved.

 BeforeAfter
PHP5.x (5.6.40 in our container)8.4.27
FrameworkCodeIgniter 1.7.2, vendored into the repositoryCodeIgniter 3.1.13, stock, byte-identical to the release
PDF enginedompdf 0.5.1, vendored; a fatal error on every PDFdompdf 3.1.6 through Composer; PDFs work
Password storagereversible mcrypt encryptionunchanged, with mcrypt from PECL; hashing rehearsed and quoted separately
Dependency managementnoneComposer, one dependency, composer audit clean
Automated tests039, green on both stacks, plus a 43-request old-against-new diff

The headline number

173
lines of application logic changed, out of 130,000 lines of diff

Twelve files, every edit traceable to a failing test and a line in a spec. The other 129,000 lines are the framework being replaced by its stock release, which is the point: nothing patched in place, and the next upgrade starts from a known tree.

What the diff contains, before tag to after tagFilesLines
Everything, including the framework swap, the PDF engine, the suite and Docker603+72,109 / −131,298
The framework itself and the removed PDF engine297+67,313 / −91,460
Configuration files, refreshed from stock twice with the application's values re-applied24+1,488 / −770
Two 1.7 libraries carried into the application unchanged (see below)2+1,371
Application code with a behaviour reason: PHP 8, or a framework change12+91 / −82
Mechanical edits: class names, constructors, file renames21+35 / −34

What the tests found before we changed anything

Fourteen findings from reading the code and pinning what it does, before a line moved. The ones an owner would want to hear first:

  1. Passwords are stored encrypted, not hashed, with the key in a config file. Anyone holding a database backup can read every password. It is also the single hardest fact in the migration: the cipher needs mcrypt, which PHP removed in 7.2.
  2. No PDF and no email had worked on any PHP from this decade. The vendored PDF engine dies on 5.6, and the 1.7 mail library trips a header guard PHP added in 5.6. Every outbound feature was dead.
  3. A fresh install does not work. The installer's own schema is wrong until the application's upgrader is run by hand.
  4. Saving the settings form silently changes the administrator's login email.
  5. Valid email addresses are refused if the top-level domain is longer than six letters.
  6. The invoice listings and the new-invoice form render PHP notices on every view.

None of these was fixed. They were pinned, so that they survive the migration exactly as they are, and listed for the owner to decide on. A migration that fixes things on the way is a migration whose diff nobody can review.

What actually broke, and who told us

Sixteen framework changes the official upgrade guides do not mention

CodeIgniter's guides are file-copy guides: replace this directory, rename that file. Seven breaks at the 1.7 to 2 hop and nine at the 2 to 3 hop were not in them: a removed validation library and its language file, a base class that moved directories, removed query-builder aliases, a model class that started proxying undeclared properties, limit('') becoming LIMIT 0, libraries loaded by a different class name, an escaped SUM(), a trailing slash kept where it used to be trimmed, a download helper that changed its default media type, a session that answers NULL where it used to answer FALSE. Every one surfaced as a red test and nowhere else. On a CodeIgniter job the safety net is not a safety net; it is the upgrade guide.

Two libraries carried, not upgraded

CodeIgniter 2 removed its validation library and changed the cipher format of its encryption library. Rewriting six forms, or re-encoding every stored password, would have been behaviour changes inside a migration. The 1.7 libraries live on as application-level copies the loader prefers, each a separate, priceable clean-up later.

Nine PHP 8 breakages in the application, two of them invisible to tools

Seven are the kind a static analyser flags: PHP 4 constructors, mktime(''), time($argument), a string grown into an array. Two are not. 0 != 'June' and "0 invoices issued this year" > 0 are valid on every PHP version and mean the opposite on PHP 8 from what they meant on PHP 5: a month heading appears, a "$0.00" line appears. No error, no deprecation, nothing from Rector or PHPStan. The only evidence was a pinned render that no longer matched. Both were preserved with a guard, and the owner gets to decide whether the heading was ever meant to be there.

The regression our own test suite missed

The XML export silently lost its <?xml ...?> prologue on PHP 8, because of a PHP 4 constructor in a helper. The suite stayed green throughout: it compares pages as text with the tags stripped, and the prologue is a tag. The old-against-new comparison, which looks at exactly what the suite normalises away, caught it on its first run.

We publish this because it is the useful lesson. Every normalisation in a test suite is a declared blind spot. The suite and the side-by-side comparison are two different instruments, and the second exists to look where the first has agreed not to.

Seven behaviour changes, declared, none silent

Three at the CodeIgniter 2 hop (a corrected header on the database backup, a warning box gone from the password-reset page, missing language lines now logged) and four at the CodeIgniter 3 hop: PDFs exist; emailing an invoice runs to its end instead of dying at the PDF, and still sends nothing because the application configures no mail transport, which is the owner's one line to add; a direct visit to the new-invoice form redirects to the new-client form, as the author's own guard always intended; redirect status codes are 307 and 303 instead of 302.

Each is asserted in its new form when the suite runs against the new stack, and a control run without that switch fails exactly those tests and nothing else. That control run is how we know the list is complete.

The password decision, rehearsed

The migration keeps every password as it is: mcrypt still builds from PECL on PHP 8.4, and the administrator created on PHP 5.6 logs in on PHP 8.4 with the same password on every run of the suite. Moving to hashed passwords is the owner's decision, so we rehearsed it on a branch and recorded both directions: a legacy value is verified the old way and replaced by a bcrypt hash at the user's next login; the hash verifies on the next; a wrong password still fails; and the old code cannot read the hash, so a rollback after that change has been live means restoring the password column from a pre-cutover backup for everyone who logged in meanwhile.

That last sentence is the quote. It is the kind of consequence an owner should hear before the work, not after.

How we proved nothing changed

From the inside

39 tests, green on the old stack (PHP 5.6, CodeIgniter 1.7.2, from a checkout of the original tree) and on the new one, on the same day. No assertion was edited at any stage. The recorded pages changed only by two declared adjustments to PHP's own notice wording, seven lines in four files.

From the outside

Both versions running at the same time, loaded with the same data. A script replays every read-only request the suite makes against both and reports one of three verdicts: match; explained, with the spec that declares the difference; or different, which fails the run. Final result: 31 match, 12 explained, 0 unexplained of 43 requests. The twelve are the seven declared changes above, the phpinfo page (it prints the PHP version), and the backup zip, whose SQL dump CodeIgniter 3 writes with quoted identifiers; compared statement by statement, identical.

What it cost

Five hours and fifteen minutes of engineering across the six stages, against three hours and twenty-five minutes for the Laravel application. The difference is where the information lives: Laravel's upgrade guides say what breaks; CodeIgniter's say which files to copy, and the suite had to discover the rest. Reviewing the work is a separate two to three hours and does not shrink.

What a CodeIgniter 3 target buys, honestly

A supported PHP, not a supported framework. CodeIgniter 3.1.13 (October 2022) is the last 3.x release and its own core trips a deprecation PHP 8.2 introduced; no upstream release will clear it, and CodeIgniter 4 is a rewrite, not an upgrade. The deprecations are invisible in production by default, we say so in the runbook, and the retainer owns the watch. We would rather tell you this before the quote than after the cutover.

What you would get

Find out where your application stands

A 48-hour exposure check is $750, credited against the migration. It confirms your real position in writing and ends with a fixed quote for the move.

Book an exposure check Run the free check first