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 withsf
, 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
(changeaffiliate
tosfJobeetAffiliate
)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.
Plugin Activation
For a plugin to be available in a project, it must be enabled in the
ProjectConfiguration
class.This step is not needed with the default configuration, as symfony has a "black-list" approach that enables all plugins except some of them:
// config/ProjectConfiguration.class.php public function setup() { $this->enableAllPluginsExcept(array('sfDoctrinePlugin', 'sfCompat10Plugin')); }This approach is needed to maintain backward compatibility with older symfony versions, but it is better to have a "white-list" approach and use the
enablePlugins()
method instead:// config/ProjectConfiguration.class.php public function setup() { $this->enablePlugins(array('sfPropelPlugin', 'sfGuardPlugin', 'sfFormExtraPlugin', 'sfJobeetPlugin')); }
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 returnstrue
when passed to theis_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:
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.
Plugin Development Tasks
If you find yourself frequently creating private and/or public plugins, consider taking advantage of some of the tasks in the sfTaskExtraPlugin. This plugin, maintained by the core team, includes a number of tasks that help you streamline the plugin lifecycle:
generate:plugin
plugin:package
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.
Very nice post. One thing that would be nice in the plugin repository is a full text search including the README, description...
I am confused with the documentation. The symfony 1.2 book states that routes cannot be generated through a custom routing.yml file, thus requiring the use of an event listener to prepend the route. Has this been changed with symfony 1.2?
It seems like this tutorial is saying one thing, and the code in SVN is saying something else.
For instance, this tutorial is saying to create the file: plugins/sfJobeetPlugin/config/sfJobeetPluginConfiguration.class.php
while SVN has this instead: http://svn.jobeet.org/tags/release_day_20/plugins/sfJobeetPlugin/config/config.php