This post was published as part of the symfony 2008 advent calendar. As this tutorial might have been updated since then, you are advised to read the last version from the symfony 1.2 documentation (for Propel or Doctrine).

Previously on Jobeet

Yesterday you learned how to internationalize and localize your symfony applications. Once again, thanks to the ICU standard and a lot of helpers, symfony makes this really easy.

Today, we will talk about plugins: what they are, what you can bundle in a plugin, and what they can be used for.

Plugins

A symfony Plugin

A symfony plugin offers a way to package and distribute a subset of your project files. Like a project, a plugin can contain classes, helpers, configuration, tasks, modules, schemas, and even web assets.

Private Plugins

The first usage of plugins is to ease sharing code between your applications, or even between different projects. Recall that symfony applications only share the model? Plugins provide a way to share more components between applications.

If you need to reuse the same schema for different projects, or the same modules, move them to a plugin. As a plugin is just a directory, you can move it around quite easily by creating a SVN repository and using svn:externals, or by just copying the files from one project to another.

We call these "private plugins" because their usage is restricted to a single developer or a company. They are not publicly available.

You can even create a package out of your private plugins, create your own symfony plugin channel, and install them via the plugin:install task.

Public Plugins

Public plugins are available for the community to download and install. During this tutorial, we have used a couple of public plugins: sfGuardPlugin and sfFormExtraPlugin.

They are exactly the same as private plugins. The only difference is that anybody can install them for their projects. You will learn later on how to publish and host a public plugin on the symfony website.

A Different Way to Organize Code

There is one more way to think about and use plugins. Forget about re-usability and sharing. Plugins can be used as a different way to organize your code. Instead of organizing the files by layers: all models in the lib/model/ directory, templates in the templates/ directory, ...; the files are put together by feature: all the job files together (the model, modules, and templates), all the CMS files together, and so on.

Plugin File Structure

A plugin is just a directory structure with files organized in a pre-defined structure, according to the nature of the files. Today, we will move most of the code we have written for Jobeet in a sfJobeetPlugin. The basic layout we will use is as follows:

sfJobeetPlugin/
  config/
    sfJobeetPluginConfiguration.class.php // Plugin initialization
    schema.yml                            // Database schema
    routing.yml                           // Routing
  lib/
    Jobeet.class.php                      // Classes
    helper/                               // Helpers
    filter/                               // Filter classes
    form/                                 // Form classes
    model/                                // Model classes
    task/                                 // Tasks
  modules/
    job/                                  // Modules
      actions/
      config/
      templates/
  web/                                    // Assets like JS, CSS, and images

The Jobeet Plugin

Bootstrapping a plugin is as simple as creating a new directory under plugins/. For Jobeet, let's create a sfJobeetPlugin directory:

$ mkdir plugins/sfJobeetPlugin

All plugins must end with Plugin. It is also a good habit to prefix them with sf, although it is not mandatory.

The Model

First, move the config/schema.yml file to plugins/sfJobeetPlugin/config/:

$ mkdir plugins/sfJobeetPlugin/config/
$ mv config/schema.yml plugins/sfJobeetPlugin/config/schema.yml

All commands are for Unix like environments. If you use Windows, you can drag and drop files in the Explorer. And if you use Subversion, or any other tool to manage your code, use the built-in tools they provide (like svn mv to move files).

Move model, form, and filter files to plugins/sfJobeetPlugin/lib/:

$ mkdir plugins/sfJobeetPlugin/lib/
$ mv lib/model/ plugins/sfJobeetPlugin/lib/
$ mv lib/form/ plugins/sfJobeetPlugin/lib/
$ mv lib/filter/ plugins/sfJobeetPlugin/lib/

If you were to run the propel:build-model task now, symfony would still generate the files under lib/model/, which is not what we want. The Propel output directory can be configured by adding a package option. Open the schema.yml and add the following configuration:

// plugins/sfJobeetPlugin/config/schema.yml
propel:
  _attributes:      { package: plugins.sfJobeetPlugin.lib.model }
 

Now symfony will generate its files under the plugins/sfJobeetPlugin/lib/model directory. The form and filter builders also take this configuration into account when they generate files.

The propel:build-sql task generates a SQL file to create tables. As the file is named after the package, remove the current one:

$ rm data/sql/lib.model.schema.sql

Now, if you run propel:build-all-load, symfony will generate files under the plugin lib/model/ directory as expected:

$ php symfony propel:build-all-load --no-confirmation

After running the task, check that no lib/model/ directory has been created. The task has created lib/form/ and lib/filter/ directories, however. They both include base classes for all Propel forms in your project.

As these files are global for a project, remove them from the plugin:

$ rm plugins/sfJobeetPlugin/lib/form/BaseFormPropel.class.php
$ rm plugins/sfJobeetPlugin/lib/filter/BaseFormFilterPropel.class.php

If you use symfony 1.2.0 or 1.2.1, the filter base form file is in the plugins/sfJobeetPlugin/lib/filter/base/ directory.

You can also move the Jobeet.class.php file to the plugin:

$ mv lib/Jobeet.class.php plugins/sfJobeetPlugin/lib/

As we have moved files around, clear the cache:

$ php symfony cc

If you use a PHP accelerator like APC and things get weird at this point, restart Apache.

Now that all the model files have been moved to the plugin, run the tests to check that everything still works fine:

$ php symfony test:all

The Controllers and the Views

The next logical step is to move the modules to the plugin:

$ mv apps/frontend/modules plugins/sfJobeetPlugin/

To avoid module name collisions, it is always a good habit to prefix plugin module names with the plugin name:

$ mv apps/frontend/modules/affiliate plugins/sfJobeetPlugin/modules/sfJobeetAffiliate
$ mv apps/frontend/modules/api plugins/sfJobeetPlugin/modules/sfJobeetApi
$ mv apps/frontend/modules/category plugins/sfJobeetPlugin/modules/sfJobeetCategory
$ mv apps/frontend/modules/job plugins/sfJobeetPlugin/modules/sfJobeetJob
$ mv apps/frontend/modules/language plugins/sfJobeetPlugin/modules/sfJobeetLanguage

For each module, you also need to change the class name in all actions.class.php and components.class.php files.

The include_partial() and include_component() calls must also be changed in the following templates:

  • sfJobeetAffiliate/templates/newSuccess.php
  • sfJobeetAffiliate/templates/_form.php (change affiliate to sfJobeetAffiliate)
  • sfJobeetCategory/templates/showSuccess.atom.php
  • sfJobeetCategory/templates/showSuccess.php
  • sfJobeetJob/templates/editSuccess.php
  • sfJobeetJob/templates/indexSuccess.atom.php
  • sfJobeetJob/templates/indexSuccess.php
  • sfJobeetJob/templates/newSuccess.php
  • sfJobeetJob/templates/searchSuccess.php
  • sfJobeetJob/templates/showSuccess.php
  • apps/frontend/templates/layout.php

Update the search and delete actions:

// plugins/sfJobeetPlugin/modules/sfJobeetJob/actions/actions.class.php
class sfJobeetJobActions extends sfActions
{
  public function executeSearch(sfWebRequest $request)
  {
    if (!$query = $request->getParameter('query'))
    {
      return $this->forward('sfJobeetJob', 'index');
    }
 
    $this->jobs = JobeetJobPeer::getForLuceneQuery($query);
 
    if ($request->isXmlHttpRequest())
    {
      if ('*' == $query || !$this->jobs)
      {
        return $this->renderText('No results.');
      }
      else
      {
        return $this->renderPartial('sfJobeetJob/list', array('jobs' => $this->jobs));
      }
    }
  }
 
  public function executeDelete(sfWebRequest $request)
  {
    $request->checkCSRFProtection();
 
    $jobeet_job = $this->getRoute()->getObject();
    $jobeet_job->delete();
 
    $this->redirect('sfJobeetJob/index');
  }
 
  // ...
}
 

Eventually, modify the routing.yml file to take these changes into account:

# apps/frontend/config/routing.yml
affiliate:
  class:   sfPropelRouteCollection
  options:
    model:          JobeetAffiliate
    actions:        [new, create]
    object_actions: { wait: GET }
    prefix_path:    /:sf_culture/affiliate
    module:         sfJobeetAffiliate
 
api_jobs:
  url:     /api/:token/jobs.:sf_format
  class:   sfPropelRoute
  param:   { module: sfJobeetApi, action: list }
  options: { model: JobeetJob, type: list, method: getForToken }
  requirements:
    sf_format: (?:xml|json|yaml)
 
category:
  url:     /:sf_culture/category/:slug.:sf_format
  class:   sfPropelRoute
  param:   { module: sfJobeetCategory, action: show, sf_format: html }
  options: { model: JobeetCategory, type: object, method: doSelectForSlug }
  requirements:
    sf_format: (?:html|atom)
 
job_search:
  url:   /:sf_culture/search.:sf_format
  param: { module: sfJobeetJob, action: search, sf_format: html }
  requirements:
    sf_format: (?:html|js)
 
job:
  class:   sfPropelRouteCollection
  options:
    model:          JobeetJob
    column:         token
    object_actions: { publish: PUT, extend: PUT }
    prefix_path:    /:sf_culture/job
    module:         sfJobeetJob
  requirements:
    token: \w+
 
job_show_user:
  url:     /:sf_culture/job/:company_slug/:location_slug/:id/:position_slug
  class:   sfPropelRoute
  options: { model: JobeetJob, type: object, method_for_criteria: doSelectActive }
  param:   { module: sfJobeetJob, action: show }
  requirements:
    id:        \d+
    sf_method: GET
 
change_language:
  url:   /change_language
  param: { module: sfJobeetLanguage, action: changeLanguage }
 
localized_homepage:
  url:   /:sf_culture/
  param: { module: sfJobeetJob, action: index }
  requirements:
    sf_culture: (?:fr|en)
 
homepage:
  url:   /
  param: { module: sfJobeetJob, action: index }
 

If you try to browse the Jobeet website now, you will have exceptions telling you that the modules are not enabled. As plugins are shared amongst all applications in a project, you need to specifically enable the module you need for a given application in its settings.yml configuration file:

// apps/frontend/config/settings.yml
all:
  .settings:
    enabled_modules:
      - default
      - sfJobeetAffiliate
      - sfJobeetApi
      - sfJobeetCategory
      - sfJobeetJob
      - sfJobeetLanguage
 

The last step of the migration is to fix the functional tests where we test for the module name.

The Tasks

Tasks can be moved to the plugin quite easily:

$ mv lib/task plugins/sfJobeetPlugin/lib/

The i18n Files

A plugin can also contain XLIFF files:

$ mv apps/frontend/i18n plugins/sfJobeetPlugin/

The Routing

A plugin can also contain routing rules:

$ mv apps/frontend/config/routing.yml plugins/sfJobeetPlugin/config/

The Assets

Even if it is a bit counter-intuitive, a plugin can also contain web assets like images, stylesheets, and JavaScripts. As we don't want to distribute the Jobeet plugin, it does not really make sense, but it is possible by creating a plugins/sfJobeetPlugin/web/ directory.

A plugin's assets must be accessible in the project's web/ directory to be viewable from a browser. The plugin:publish-assets addresses this by creating symlinks under Unix system and by copying the files on the Windows platform:

$ php symfony plugin:publish-assets

The User

Moving the myUser class methods that deal with job history is a bit more involved. We could create a JobeetUser class and make myUser inherit from it. But there is a better way, especially if several plugins want to add new methods to the class.

Core symfony objects notify events during their life-cycle that you can listen to. In our case, we need to listen to the user.method_not_found event, which occurs when an undefined method is called on the sfUser object.

When symfony is initialized, all plugins are also initialized if they have a plugin configuration class:

// plugins/sfJobeetPlugin/config/sfJobeetPluginConfiguration.class.php
class sfJobeetPluginConfiguration extends sfPluginConfiguration
{
  public function initialize()
  {
    $this->dispatcher->connect('user.method_not_found', array('JobeetUser', 'methodNotFound'));
  }
}
 

Event notifications are managed by the event dispatcher object (sfEventDispatcher). Registering a listener is as simple as calling connect(). The connect() method connects an event name to a PHP callable.

A PHP callable is a PHP variable that can be used by the call_user_func() function and returns true when passed to the is_callable() function. A string represents a function, and an array can represent an object method or a class method.

With the above code in place, myUser object will call the static methodNotFound() method of the JobeetUser class whenever it is unable to find a method. It is then up to the methodNotFound() method to process the missing method or not.

Remove all methods from the myUser class and create the JobeetUser class:

// apps/frontend/lib/myUser.class.php
class myUser extends sfBasicSecurityUser
{
}
 
// plugins/sfJobeetPlugin/lib/JobeetUser.class.php
class JobeetUser
{
  static public function methodNotFound(sfEvent $event)
  {
    if (method_exists('JobeetUser', $event['method']))
    {
      $event->setReturnValue(call_user_func_array(
        array('JobeetUser', $event['method']),
        array_merge(array($event->getSubject()), $event['arguments'])
      ));
 
      return true;
    }
  }
 
  static public function isFirstRequest(sfUser $user, $boolean = null)
  {
    if (is_null($boolean))
    {
      return $user->getAttribute('first_request', true);
    }
    else
    {
      $user->setAttribute('first_request', $boolean);
    }
  }
 
  static public function addJobToHistory(sfUser $user, JobeetJob $job)
  {
    $ids = $user->getAttribute('job_history', array());
 
    if (!in_array($job->getId(), $ids))
    {
      array_unshift($ids, $job->getId());
      $user->setAttribute('job_history', array_slice($ids, 0, 3));
    }
  }
 
  static public function getJobHistory(sfUser $user)
  {
    return JobeetJobPeer::retrieveByPks($user->getAttribute('job_history', array()));
  }
 
  static public function resetJobHistory(sfUser $user)
  {
    $user->getAttributeHolder()->remove('job_history');
  }
}
 

When the dispatcher calls the methodNotFound() method, it passes a sfEvent object.

If the method exists in the JobeetUser class, it is called and its returned value is subsequently returned to the notifier. If not, symfony will try the next registered listener or throw an Exception.

The getSubject() method returns the notifier of the event, which in this case is the current myUser object.

As always when you create new classes, don't forget to clear the cache before browsing or running the tests:

$ php symfony cc

The Default Structure vs. the Plugin Architecture

Using the plugin architecture allows you to organize your code in a different way:

Plugin Architecture

Using Plugins

When you start implementing a new feature, or if you try to solve a classic web problem, odds are that someone has already solved the same problem and perhaps packaged the solution as a symfony plugin. To you look for a public symfony plugin, go to the plugin section of the symfony website.

As a plugin is self-contained in a directory, there are several way to install it:

  • Using the plugin:install task (it only works if the plugin developer has created a plugin package and uploaded it on the symfony website)
  • Downloading the package and manually un-archive it under the plugins/ directory (it also need that the developer has uploaded a package)
  • Creating a svn:externals in plugins/ for the plugin (it only works if the plugin developer host its plugin on Subversion)

The last two ways are easy but lack some flexibility. The first way allows you to install the latest version according to the project symfony version, easily upgrade to the latest stable release, and to easily manage dependencies between plugins.

Contributing a Plugin

Packaging a Plugin

To create a plugin package, you need to add some mandatory files to the plugin directory structure. First, create a README file at the root of the plugin directory and explain how to install the plugin, what it provides, and what not. The README file must be formatted with the Markdown format. This file will be used on the symfony website as the main piece of documentation. You can test the conversion of your README file to HTML by using the symfony plugin dingus.

You also need to create a LICENSE file. Choosing a license is not an easy task, but the symfony plugin section only lists plugins that are released under a license similar to the symfony one (MIT, BSD, LGPL, and PHP). The content of the LICENSE file will be displayed under the license tab of your plugin's public page.

The last step is to create a package.xml file at the root of the plugin directory. This package.xml file follows the PEAR package syntax.

The best way to learn the package.xml syntax is certainly to copy the one used by an existing plugin.

The package.xml file is composed of several parts as you can see in this template example:

<!-- plugins/sfJobeetPlugin/package.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<package packagerversion="1.4.1" version="2.0"
   xmlns="http://pear.php.net/dtd/package-2.0"
   xmlns:tasks="http://pear.php.net/dtd/tasks-1.0"
   xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
   xsi:schemaLocation="http://pear.php.net/dtd/tasks-1.0
   http://pear.php.net/dtd/tasks-1.0.xsd http://pear.php.net/dtd/package-2.0
   http://pear.php.net/dtd/package-2.0.xsd"
>
  <name>sfJobeetPlugin</name>
  <channel>plugins.symfony-project.org</channel>
  <summary>A job board plugin.</summary>
  <description>A job board plugin.</description>
  <lead>
    <name>Fabien POTENCIER</name>
    <user>fabpot</user>
    <email>fabien.potencier@symfony-project.com</email>
    <active>yes</active>
  </lead>
  <date>2008-12-20</date>
  <version>
    <release>1.0.0</release>
    <api>1.0.0</api>
  </version>
  <stability>
    <release>stable</release>
    <api>stable</api>
  </stability>
  <license uri="http://www.symfony-project.com/license">
    MIT license
  </license>
  <notes />
 
  <contents>
    <!-- CONTENT -->
  </contents>
 
  <dependencies>
   <!-- DEPENDENCIES -->
  </dependencies>
 
  <phprelease>
</phprelease>
 
<changelog>
  <!-- CHANGELOG -->
</changelog>
</package>
 

The <content> tag contains the files that need to be put into the package:

<contents>
  <dir name="/">
    <file role="data" name="README" />
    <file role="data" name="LICENSE" />
 
    <dir name="config">
      <file role="data" name="config.php" />
      <file role="data" name="schema.yml" />
    </dir>
 
    <!-- ... -->
  </dir>
</contents>
 

The <dependencies> tag references all dependencies the plugin might have: PHP, symfony, and also other plugins. This information is used by the plugin:install task to install the best plugin version for the project environment and to also install required plugin dependencies if any.

<dependencies>
  <required>
    <php>
      <min>5.0.0</min>
    </php>
    <pearinstaller>
      <min>1.4.1</min>
    </pearinstaller>
    <package>
      <name>symfony</name>
      <channel>pear.symfony-project.com</channel>
      <min>1.2.0</min>
      <max>1.3.0</max>
      <exclude>1.3.0</exclude>
    </package>
  </required>
</dependencies>
 

You should always declare a dependency on symfony, as we have done here. Declaring a minimum and a maximum version allows the plugin:install to know what symfony version is mandatory as symfony versions can have slightly different APIs.

Declaring a dependency with another plugin is also possible:

<package>
  <name>sfFooPlugin</name>
  <channel>plugins.symfony-project.org</channel>
  <min>1.0.0</min>
  <max>1.2.0</max>
  <exclude>1.2.0</exclude>
</package>
 

The <changelog> tag is optional but gives useful information about what changed between releases. This information is available under the "Changelog" tab and also in the plugin feed.

<changelog>
  <release>
    <version>
      <release>1.0.0</release>
 
      <api>1.0.0</api>
    </version>
    <stability>
      <release>stable</release>
      <api>stable</api>
    </stability>
    <license uri="http://www.symfony-project.com/license">
      MIT license
    </license>
    <date>2008-12-20</date>
    <license>MIT</license>
    <notes>
       * fabien: First release of the plugin
    </notes>
  </release>
</changelog>
 

Hosting a Plugin on the symfony Website

If you develop a useful plugin and you want to share it with the symfony community, create a symfony account if you don't have one already and then, create a new plugin.

You will automatically become the administrator for the plugin and you will see an "admin" tab in the interface. In this tab, you will find everything you need to manage your plugin and upload your packages.

The plugin FAQ contains a lot of useful information for plugin developers.

See you Tomorrow

Creating plugins, and sharing them with the community is one of the best way to contribute back to the symfony project. It is so easy, that the symfony plugin repository is full of useful, fun, but also ridiculous plugins.

Tomorrow, to celebrate the winter, we will organize a "design day" contest and the community will have to choose the default design that will be bundled with Jobeet.

Published in #Tutorials