Tip of the day: Phing, Composer and namespaced Task classes

Submitted by Frederic Marand on

Among the interesting features of Phing is its extensibility, and of the hallmarks of that exensibility is the ability to define new Task types as PHP classes, which are by default located in the default namespace only. Can we do better ?

Step 0: "Ad-hoc" (inline) tasks

At the simplest, Task classes are created by embedding ad-hoc tasks like:

<
  <!-- somewhere in build.xml ->
  <target name="defineadhoc" hidden="true">
    <adhoc-task name="ah1"><![CDATA[
      class SomeAdHocTaskClass extends Task {
        private $arg1;

        function setAh1Arg1($arg1) {
          $this->arg1 = $arg1;
        }

        function main() {
          $this->log("In " . __METHOD__ . ", arg1 = " . $this->arg1);
        }
      }
    ]]></adhoc-task>
  </target>

  <target name="useadhoc" depends="defineadhoc" description="Demo: use an ad-hoc class">
    <ah1 ah1Arg1="foo" />
  </target>

Step 1: Rip code out of build.xml

Embedding such logic in a build file is not acceptable in most cases, so let's jump to the <taskdef /> element and define our task in its own file:

<?php
class DemoTask extends Task {

  protected
$arg;

  public function
setArg($arg) {
   
$this->arg = strrev($arg);
  }

  public function
main() {
    echo
"In " . __METHOD__ . ", arg = " . $this->arg . "\n";
  }
}
?>

By default, Phing will try to load the DemoTask from the current directory, but it offers a mechanism to fetch the file either from the PHP class path, or from an explicit path, like this:

  <taskdef name="demo" classname="src.Acme.DemoTask" />

In this example, it will try to load a class called DemoTask from a DemoTask.php file in the src/Acme/ directory. Assuming a typical PSR-0/PSR-4 deployment for a Silex of Symfony2 project, we are likely to write our code in a specific directory under src/ or maybe within a Bundle directory. Supposing we place it in src/Acme/Build/DemoTask.php, we can tweak the Phing build file to load from there:

  <taskdef name="demo" classname="src.Acme.Build.DemoTask" />

Step 2: Enter PSR-0 autoloading

The namespace vs path problem

The problem with the previous approach is that it breaks PSR-0 conventions. Assuming a typicaly autoloading root of src/, if DemoTask is located in src/Acme/Build/DemoTask.php, then the fully qualified class name should be Acme\Build\DemoTask. But if we modify the class accordingly, Phing will not accept that class: in a Composer-deployed Phing we will get something like this:

<?php
namespace Acme\Build;

class
DemoTask extends \Task { /* ... */ }
?>
  <!-- in build.xml -->
  <taskdef name="demo" classname="src.Acme.Build.DemoTask" />

  <target name="usetask">
    <demo arg="some value" />
  </target>

bin/phing usetask
Buildfile: <...>/build.xml

> usetask:

Execution of target "usetask" failed for the following reason: <...>/build.xml:53:14: Could not create task of type: demo
#0 <...>/vendor/phing/phing/classes/phing/UnknownElement.php(191): Project->createTask('demo')
#1 <...>/vendor/phing/phing/classes/phing/UnknownElement.php(171): UnknownElement->makeTask(Object(UnknownElement), Object(RuntimeConfigurable), true)
#2 <...>/vendor/phing/phing/classes/phing/UnknownElement.php(70): UnknownElement->makeObject(Object(UnknownElement), Object(RuntimeConfigurable))
#3 <...>/vendor/phing/phing/classes/phing/Task.php(259): UnknownElement->maybeConfigure()
#4 <...>/vendor/phing/phing/classes/phing/Target.php(297): Task->perform()
#5 <...>/vendor/phing/phing/classes/phing/Target.php(320): Target->main()
#6 <...>/vendor/phing/phing/classes/phing/Project.php(824): Target->performTasks()
#7 <...>/vendor/phing/phing/classes/phing/Project.php(797): Project->executeTarget('usetask')
#8 <...>/vendor/phing/phing/classes/phing/Phing.php(586): Project->executeTargets(Array)
#9 <...>/vendor/phing/phing/classes/phing/Phing.php(170): Phing->runBuild()
#10 <...>/vendor/phing/phing/classes/phing/Phing.php(278): Phing::start(Array, NULL)
#11 <...>/vendor/phing/phing/bin/phing.php(43): Phing::fire(Array)
#12 <...>/vendor/phing/phing/bin/phing(20): require_once('<...>...')
#13 {main}

Previous exception 'BuildException' with message 'Could not instantiate class DemoTask, even though a class was specified. (Make sure that the specified class file contains a class with the correct name.)' in <...>/vendor/phing/phing/classes/phing/Project.php:679

[...]

The solution: autoloading

Looking more closely, Phing does not object to loading such a namespaced class, the only problem is that it cannot locate it in the file it expects. What if it could rely on Composer autoloading ? Let us be sure src/ is a default autoloading directory by defining it in composer.json in case it is not already there:

  "autoload": {
    "psr-0": {
      "": "src/"
    }
  },

And now let us remove the explicit path in build.xml:

  <!-- in build.xml -->
  <taskdef name="demo" classname="Acme\Build\DemoTask" />

  <target name="usetask">
    <demo arg="some value" />
  </target>

bin/phing usetask

Buildfile: <...>/build.xml

> usetask:

In Acme\Build\DemoTask::main, arg = eulav emos

BUILD FINISHED

Total time: 0.0699 seconds

Thanks to Composer PSR-0 autoloading, we have been able at the same time to gain two benefits:

  • remove explicit path configuration in build.xml
  • support namespaced Task classes.