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.
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.
| Before | After | |
|---|---|---|
| PHP | 5.x (5.6.40 in our container) | 8.4.27 |
| Framework | CodeIgniter 1.7.2, vendored into the repository | CodeIgniter 3.1.13, stock, byte-identical to the release |
| PDF engine | dompdf 0.5.1, vendored; a fatal error on every PDF | dompdf 3.1.6 through Composer; PDFs work |
| Password storage | reversible mcrypt encryption | unchanged, with mcrypt from PECL; hashing rehearsed and quoted separately |
| Dependency management | none | Composer, one dependency, composer audit clean |
| Automated tests | 0 | 39, green on both stacks, plus a 43-request old-against-new 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 tag | Files | Lines |
|---|---|---|
| Everything, including the framework swap, the PDF engine, the suite and Docker | 603 | +72,109 / −131,298 |
| The framework itself and the removed PDF engine | 297 | +67,313 / −91,460 |
| Configuration files, refreshed from stock twice with the application's values re-applied | 24 | +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 change | 12 | +91 / −82 |
| Mechanical edits: class names, constructors, file renames | 21 | +35 / −34 |
Fourteen findings from reading the code and pinning what it does, before a line moved. The ones an owner would want to hear first:
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.
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.
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.
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 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.
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 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.
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.
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.
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.
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.
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