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. Until the end of these lines, 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.
tip
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: sfDoctrineGuardPlugin
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 plugins and how to use them. 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 job files together (the model, modules, and
templates), all 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. Here, 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 routing.yml // Routing doctrine/ schema.yml // Database schema 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
Then, activate the sfJobeetPlugin
in config/ProjectConfiguration.class.php
file.
public function setup() { $this->enablePlugins(array( 'sfDoctrinePlugin', 'sfDoctrineGuardPlugin', 'sfFormExtraPlugin', 'sfJobeetPlugin' )); }
note
All plugins must end with the Plugin
suffix. It is also a good habit to
prefix them with sf
, although it is not mandatory.
The Model
First, move the config/doctrine/schema.yml
file to plugins/sfJobeetPlugin/config/
:
$ mkdir plugins/sfJobeetPlugin/config/ $ mkdir plugins/sfJobeetPlugin/config/doctrine $ mv config/doctrine/schema.yml plugins/sfJobeetPlugin/config/doctrine/schema.yml
note
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/ $ rm -rf plugins/sfJobeetPlugin/lib/model/doctrine/sfDoctrineGuardPlugin $ rm -rf plugins/sfJobeetPlugin/lib/form/doctrine/sfDoctrineGuardPlugin $ rm -rf plugins/sfJobeetPlugin/lib/filter/doctrine/sfDoctrineGuardPlugin
Remove the plugins/sfJobeetPlugin/lib/form/BaseForm.class.php
file.
$ rm plugins/sfJobeetPlugin/lib/form/BaseForm.class.php
After you move the models, forms and filters the classes must be renamed, made
abstract and prefixed with the word Plugin
.
tip
Only prefix the auto-generated classes with Plugin
and not all
classes. For example do not prefix any classes you wrote by hand. Only the
auto-generated ones require the prefix.
Here is an example where we move the JobeetAffiliate
and
JobeetAffiliateTable
classes.
$ mv plugins/sfJobeetPlugin/lib/model/doctrine/JobeetAffiliate.class.php plugins/sfJobeetPlugin/lib/model/doctrine/PluginJobeetAffiliate.class.php
And the code should be updated:
abstract class PluginJobeetAffiliate extends BaseJobeetAffiliate { public function save(Doctrine_Connection $conn = null) { if (!$this->getToken()) { $this->setToken(sha1($object->getEmail().rand(11111, 99999))); } parent::save($conn); } // ... }
Now lets move the JobeetAffiliateTable
class:
$ mv plugins/sfJobeetPlugin/lib/model/doctrine/JobeetAffiliateTable.class.php plugins/sfJobeetPlugin/lib/model/doctrine/PluginJobeetAffiliateTable.class.php
The class definition should now look like the following:
abstract class PluginJobeetAffiliateTable extends Doctrine_Table { // ... }
Now do the same thing for the forms and filter classes. Rename them to include a
prefix with the word Plugin
.
Make sure to remove the base
directory in
plugins/sfJobeetPlugin/lib/*/doctrine/
for form
, filter
, and model
directories:
$ rm -rf plugins/sfJobeetPlugin/lib/form/doctrine/base $ rm -rf plugins/sfJobeetPlugin/lib/filter/doctrine/base $ rm -rf plugins/sfJobeetPlugin/lib/model/doctrine/base
Once you have moved, renamed and removed some forms, filters and model classes run the tasks to build the re-build all the classes:
$ php symfony doctrine:build --all-classes
Now you will notice some new directories created to hold the models created from
the schema included with the sfJobeetPlugin
at
lib/model/doctrine/sfJobeetPlugin/
.
This directory contains the top level models and the base classes generated from
the schema. For example the model JobeetJob
now has this class structure:
JobeetJob
(extendsPluginJobeetJob
) inlib/model/doctrine/sfJobeetPlugin/JobeetJob.class.php
: Top level class where all project model functionality can be placed. This is where you can add and override functionality that comes with the plugin models.PluginJobeetJob
(extendsBaseJobeetJob
) inplugins/sfJobeetPlugin/lib/model/doctrine/PluginJobeetJob.class.php
: This class contains all the plugin specific functionality. You can override functionality in this class and the base by modifying theJobeetJob
class.BaseJobeetJob
(extendssfDoctrineRecord
) inlib/model/doctrine/sfJobeetPlugin/base/BaseJobeetJob.class.php
: Base class that is generated from the yaml schema file each time you rundoctrine:build --model
.JobeetJobTable
(extendsPluginJobeetJobTable
) inlib/model/doctrine/sfJobeetPlugin/JobeetJobTable.class.php
: Same as theJobeetJob
class except this is the instance ofDoctrine_Table
that will be returned when you callDoctrine_Core::getTable('JobeetJob')
.PluginJobeetJobTable
(extendsDoctrine_Table
) inlib/model/doctrine/sfJobeetPlugin/JobeetJobTable.class.php
: This class contains all the plugin specific functionality for the instance ofDoctrine_Table
that will be returned when you callDoctrine_Core::getTable('JobeetJob')
.
With this generated structure you have the ability to customize the models of a
plugin by editing the top level JobeetJob
class. You can customize the schema
and add columns, add relationships by overriding the setTableDefinition()
and
setUp()
methods.
note
When you move the form classes, be sure to change the configure()
method to
a setup()
method and call parent::setup()
. Below is an example.
abstract class PluginJobeetAffiliateForm extends BaseJobeetAffiliateForm { public function setup() { parent::setup(); } // ... }
We need to make sure our plugin doesn't have the base classes for all Doctrine
forms. These files are global for a project and will be re-generated with the
doctrine:build --forms
and doctrine:build --filters
.
Remove the files from the plugin:
$ rm plugins/sfJobeetPlugin/lib/form/doctrine/BaseFormDoctrine.class.php $ rm plugins/sfJobeetPlugin/lib/filter/doctrine/BaseFormFilterDoctrine.class.php
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
tip
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. To avoid module name collisions, it is always a good habit to prefix plugin module names with the plugin name:
$ mkdir plugins/sfJobeetPlugin/modules/ $ 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 (for instance, the
affiliateActions
class needs to be renamed to sfJobeetAffiliateActions
).
The include_partial()
and include_component()
calls must also be changed in
the following templates:
sfJobeetAffiliate/templates/_form.php
(changeaffiliate
tosfJobeetAffiliate
)sfJobeetCategory/templates/showSuccess.atom.php
sfJobeetCategory/templates/showSuccess.php
sfJobeetJob/templates/indexSuccess.atom.php
sfJobeetJob/templates/indexSuccess.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) { $this->forwardUnless($query = $request->getParameter('query'), 'sfJobeetJob', 'index'); $this->jobs = Doctrine_Core::getTable('JobeetJob') ->getForLuceneQuery($query); if ($request->isXmlHttpRequest()) { if ('*' == $query || !$this->jobs) { return $this->renderText('No results.'); } 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'); } // ... }
Now, modify the routing.yml
file to take these changes into account:
# apps/frontend/config/routing.yml affiliate: class: sfDoctrineRouteCollection options: model: JobeetAffiliate actions: [new, create] object_actions: { wait: GET } prefix_path: /:sf_culture/affiliate module: sfJobeetAffiliate requirements: sf_culture: (?:fr|en) api_jobs: url: /api/:token/jobs.:sf_format class: sfDoctrineRoute 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: sfDoctrineRoute param: { module: sfJobeetCategory, action: show, sf_format: html } options: { model: JobeetCategory, type: object, method: doSelectForSlug } requirements: sf_format: (?:html|atom) sf_culture: (?:fr|en) job_search: url: /:sf_culture/search param: { module: sfJobeetJob, action: search } requirements: sf_culture: (?:fr|en) job: class: sfDoctrineRouteCollection options: model: JobeetJob column: token object_actions: { publish: PUT, extend: PUT } prefix_path: /:sf_culture/job module: sfJobeetJob requirements: token: \w+ sf_culture: (?:fr|en) job_show_user: url: /:sf_culture/job/:company_slug/:location_slug/:id/:position_slug class: sfDoctrineRoute options: model: JobeetJob type: object method_for_query: retrieveActiveJob param: { module: sfJobeetJob, action: show } requirements: id: \d+ sf_method: GET sf_culture: (?:fr|en) 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
sfEventDispatcher
,
the event dispatcher object. Registering a listener is as simple as calling the
connect()
method. The connect()
method connects an event name to a PHP
callable.
note
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) { $ids = $user->getAttribute('job_history', array()); if (!empty($ids)) { return Doctrine_Core::getTable('JobeetJob') ->createQuery('a') ->whereIn('a.id', $ids) ->execute(); } return 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.
The Default Structure vs. the Plugin Architecture
Using the plugin architecture allows you to organize your code in a different way:
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
inplugins/
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.
note
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 <contents>
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.3.0</min> <max>1.5.0</max> <exclude>1.5.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.
note
The plugin FAQ contains a lot of useful information for plugin developers.
Final Thoughts
Creating plugins, and sharing them with the community is one of the best ways 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.
This work is licensed under the Creative Commons Attribution-Share Alike 3.0 Unported License license.