Introducing Composer to a CodeIgniter application

There are four third-party libraries in application/libraries. None carries a version number anywhere — not in a file, not in a comment, not in the history — and two have been edited in place. When the PDF renderer started producing blank second pages, the first job was not fixing it but working out which release it had been, and what somebody had done to it since.

The symptom

The directory listing is the whole problem. Four files, four dates, no other information — and a @version comment in the PDF file saying 1.53, against an upstream that is on 5.9.

$ ls -l application/libraries
-rw-r--r-- 1 www-data www-data  412803 Mar  3  2010 Pdf.php
-rw-r--r-- 1 www-data www-data   28114 Nov 18  2010 Payment.php
-rw-r--r-- 1 www-data www-data   64990 Jun  9  2011 Image.php
-rw-r--r-- 1 www-data www-data  190446 Feb 22  2011 Spreadsheet.php

The only way to measure the gap is to fetch 1.53 from the project’s own site and diff against it. Forty-seven added lines, in two places:

$ diff -u ~/src/pdf-1.53/pdf.php application/libraries/Pdf.php | head -8
@@ -1204,7 +1204,7 @@
-        $this->SetFont('helvetica', '', 8);
+        $this->SetFont('dejavusans', '', 8);
@@ -3391,6 +3391,9 @@
+        // sets Content-Length from an unflushed buffer, truncating it.
+        // header('Content-Length: ' . strlen($buffer));

One is a font substitution that should have been a configuration value. The other is a real bug in the library, found by somebody who fixed it locally and told nobody. Upstream still has it.

Why it happens

CodeIgniter resolves a class by file position: 'pdf' passed to the loader means application/libraries/Pdf.php, falling back to system/libraries/Pdf.php. There is no other input to the decision. That is reasonable for a framework built to work when dropped onto shared hosting, and it has one consequence: the only way to pin a version is to put that version’s file in that directory. Copying is the version pin.

Copying is also the invitation. Once a file sits in the application’s own tree it looks like code the team wrote, and editing it is a two-line change like any other. The 2010 edit was almost certainly correct — it was simply never sent anywhere, so upstream still has the bug and this copy can never be upgraded past it.

The fix

A composer.json, and an autoloader in front of the boot

Composer had its first tagged release in March, and Packagist is small enough that two of these four libraries are not on it at all. This stage deals with the two that are.

{
    "require": {
        "php": ">=5.3.3",
        "swiftmailer/swiftmailer": "4.1.*",
        "monolog/monolog": "1.0.*"
    },
    "config": {
        "vendor-dir": "application/vendor"
    }
}

The vendor directory goes inside application/ because on this host the project root is the web root, and application/ already has an .htaccess denying everything under it. Not elegant, but it is the difference between a private directory and a public one.

The autoloader has to be in place before CodeIgniter boots, which puts it at the top of index.php and nowhere else:

// index.php — the one line that has to come first
require_once __DIR__ . '/application/vendor/autoload.php';

// CodeIgniter's own index.php from here down, unmodified
require_once BASEPATH . 'core/CodeIgniter.php';

Note

A hook would be tidier, and pre_system looks as though it is meant for this. It is not early enough: hooks are loaded by the framework, so one registered there runs after the core files are already included.

The two libraries somebody had edited

The image library turned out to be nothing: whitespace, a removed error_reporting() call, and an error-suppression operator in front of a function that was already safe. It went into composer.json at the version it had been, then to current in a second commit.

The PDF renderer was the real one. Both changes were written up before anything was touched, because the moment the vendored copy is gone they exist only in a diff nobody will read again. The font became a constructor argument; the bug became a subclass:

// application/src/Shop/Pdf/Report.php
class Shop_Pdf_Report extends Pdf
{
    public function Output($name = 'report.pdf', $dest = 'I')
    {
        // Upstream sets Content-Length from a buffer it has not flushed,
        // which truncates the download. Reported as #418.
        ob_end_clean();
        return parent::Output($name, $dest);
    }
}

Nine visible lines in the application instead of three invisible ones removed from a library. When the upstream fix lands the subclass gets deleted, and that deletion is a change with a reason attached rather than a silent overwrite during an upgrade.

Warning

Write down what a local modification was for before you remove it. A diff against a release you no longer have installed is not recoverable, and “somebody changed this in 2010” keeps a vendored copy alive for another three years.

PSR-0 for the application’s own classes

The autoloader is not only for vendor/. The same block covers the application, which finally gives this codebase somewhere to put a class that is neither a controller nor a model.

{
    "autoload": {
        "psr-0": { "Shop_": "application/src/" }
    }
}

PSR-0 maps underscores onto directory separators — the Zend Framework 1 convention, written down and made portable — so Shop_Catalogue_Price lives at application/src/Shop/Catalogue/Price.php; there is a note on the underscore rule separately. It cannot collide with the framework: CodeIgniter’s loader looks only inside models, libraries and controllers, and only when asked.

Namespaces would work too, and this box is on 5.4 now. I did not use them, because half the classes under application/models are called things like Order_model and the framework requires that name; one old-fashioned convention across the tree beat two split down the middle of it.

Deployment, and the question everyone asks first

vendor/ goes into the repository. All of it, committed, and the deploy stays a checkout.

The alternative is composer install on the server, which in 2012 makes the deploy depend on Packagist being up, on GitHub being up, on every source repository the resolver decides to clone being up, and on the production box having the memory to run a dependency solver.

I expect this to look wrong in a few years. What makes it defensible now is that composer.lock is committed alongside, so the tree and the lock file can be checked against each other.

Verifying it worked

The check that means anything is a clean checkout on a machine that has never seen this application — the only way to find the file you forgot to commit.

$ git clone [email protected]:shop.git /tmp/check && cd /tmp/check
$ php -l index.php
No syntax errors detected in index.php
$ php -r "require 'application/vendor/autoload.php'; new Shop_Catalogue_Price(1000, 20);"

And then the part that had never been possible: constructing one class without booting the framework.

class Shop_Catalogue_PriceTest extends PHPUnit_Framework_TestCase
{
    public function testVatIsAddedAtTheStandardRate()
    {
        $price = new Shop_Catalogue_Price(1000, 20);
        $this->assertEquals(1200, $price->inclusive());
    }
}

PHPUnit 3.6 from PEAR — it is not something Composer installs yet — with a bootstrap attribute in phpunit.xml pointing at the same autoload.php. Before this, the shortest route to an object was loading the whole framework, which is how the first test written here ended up testing a controller.

What this costs

The resolver is slow and hungry: composer update against this file takes about ninety seconds and needs the memory limit well past 256 MB, which is why the documented answer is php -d memory_limit=-1 composer.phar update. Installing from a lock file is far cheaper.

Packagist is young enough that choosing a package is guesswork: two of these four libraries are not on it, and one of the two that is was published by somebody unrelated to the project. There is no download count worth reading yet, and no way to tell an official package from a helpful mirror except by following the links back to the project’s own site.

And there is one more thing that can be down, which is genuinely new: a PHP application used to have no build step at all. I have traded a directory of files nobody could upgrade for a dependency on a registry that did not exist last year. That is a good trade on three months of evidence — but it is a trade, and the second half of it has not been tested by anything difficult yet.