Customizing a plugin schema
In the evolution of symfony, plugins are an important step. But before we can think of applications made entirely with plugins - the 'Lego' dream - the framework must offer a way to customize plugin schemas.
Why plugin schemas must be configurable
More and more symfony plugins offer high-level features, even entire modules, based on a bundled schema. The sfGuardPlugin, for instance, defines several tables for user management. The sfSimpleForumPlugin defines tables for forums, topics and posts, in order to provide a complete forum packaged in one plugin.
The problem is that unlike all the other parts of a plugin, a plugin schema can't be customized. This is a very strong limitation, and it makes plugins impossible to use in many cases.
For instance, the sfGuardUser table doesn't have an email column. As its schema can't be customized, this means that if you want to use sfGuard and handle user emails (that should be about 99% of the cases), then you have to define a new table for user details with a 1:1 relationship to the sfGuardUser table. Bummer.
Worse, if you want to use a behavior, like for instance the sfPropelParanoidBehavior, on a plugin model, you simply can't. The said behavior needs an extra deleted_at column to be added to the table, and you can't add it to a plugin schema - or else it will be deleted the next time you update the plugin.
Using PHP in YAML files
But nothing is impossible with symfony. By using two powerful features of the symfony YAML parser, it is possible to make a schema more configurable.
First, you probably know that symfony sees YAML files as PHP, and therefore executes them before interpreting the YAML inside. This means that a YAML schema can include PHP code, and that gives a certain level of customization to schemas. For instance, look at how the sfSimpleBloginPlugin schema offers a way to change the connection and tables names:
<?php
// Default values
$config = array(
'connection' => 'propel',
'user_table' => 'sf_guard_user',
'user_id' => 'id',
'post_table' => 'sf_blog_post',
...
);
// Check custom project values in my_project/config/sfBlogPlugin.yml
if(is_readable($config_file = sfConfig::get('sf_config_dir').'/sfBlogPlugin.yml'))
{
$user_config = sfYaml::load($config_file);
if(isset($user_config['schema']))
{
$config = array_merge($config, $user_config['schema']);
}
}
// in plugins/sfSimpleBlogPlugin/config/schema.yml
<?php include('schemaConfig.php') ?>
<?php echo $config['connection'] ?>:
_attributes: { package: plugins.sfSimpleBlogPlugin.lib.model }
<?php echo $config['post_table'] ?>:
_attributes: { phpName: sfSimpleBlogPost }
id: ~
author_id: { type: integer, foreignTable: <?php echo $config['user_table'] ?>, foreignReference: <?php echo $config['user_id'] ?>, onDelete: cascade }
title: varchar(255)
stripped_title: { type: varchar(255), index: unique }
extract: longvarchar
content: longvarchar
is_published: { type: boolean, default: false }
allow_comments: { type: boolean, default: true }
created_at: ~
...
To customize table names, you just need to create a config/sfBlogPlugin.yml overriding the values of the $config array, like for instance:
schema:
connection: foo
user_table: my_user
post_table: bar_post
In a previous post, I explained how a custom config handler can make this task even easier.
This is more or less a hack, and it allows only the customization of predefined elements of the schema. Besides, PHP code makes the YAML schema less readable.
A YAML file is an array
A second property of YAML files in symfony is that, if they return a PHP array, then the YAML parser won't be invoked at all. Imagine the following YAML file:
Since the YAML file returns an array, the YAML code is ignored. Everything happens as if the file had been:
Combining this property and the power of sfToolkit::arrayDeepMerge(), it is possible to allow more configuration to plugin schemas. Look at how it is done in the sfSimpleForumPlugin:
<?php
function start_schema()
{
ob_start();
}
function end_schema()
{
$tables = array(
'connection' => 'propel',
'user_table' => 'sf_guard_user',
'user_id' => 'id',
'forum_table' => 'sf_simple_forum_forum',
'topic_table' => 'sf_simple_forum_topic',
'post_table' => 'sf_simple_forum_post',
...
);
$custom_fields = array();
// Check custom project values in my_project/config/sfBlogPlugin.yml
if(is_readable($config_file = sfConfig::get('sf_config_dir').'/sfSimpleForumPlugin-schema-custom.yml'))
{
$user_config = sfYaml::load($config_file);
if(isset($user_config['tables']))
{
$tables = array_merge($tables, $user_config['tables']);
}
if(isset($user_config['custom_fields']))
{
$custom_fields = $user_config['custom_fields'];
}
}
$yaml_schema = ob_get_clean();
foreach ($tables as $key => $value)
{
$yaml_schema = str_replace('%'.$key.'%', $value, $yaml_schema);
}
$schema = sfYaml::load($yaml_schema);
$schema[$tables['connection']] = sfToolkit::arrayDeepMerge($schema[$tables['connection']], $custom_fields);
return $schema;
}
// in plugins/sfSimpleForumPlugin/config/schema.yml
<?php include_once('schemaConfig.php') ?>
<?php start_schema() ?>
%connection%:
...
%topic_table%:
_attributes: { phpName: sfSimpleForumTopic, package: plugins.sfSimpleForumPlugin.lib.model }
id:
title: varchar(255)
is_sticked: { type: boolean, default: false }
is_locked: { type: boolean, default: false }
forum_id: { type: integer, foreignTable: %forum_table%, foreignReference: id, onDelete: cascade }
created_at:
updated_at:
# performance enhancers
latest_post_id: { type: integer, foreignTable: %post_table%, foreignReference: id, onDelete: setnull }
user_id: { type: integer, foreignTable: %user_table%, foreignReference: %user_id%, onDelete: setnull }
stripped_title: varchar(255)
nb_posts: { type: bigint, default: 0 }
nb_views: { type: bigint, default: 0 }
...
<?php return end_schema() ?>
Not only is the schema more readable (the embedded PHP statements were replaced by tokens), it is now possible to add new fields to the schema:
tables:
topic_table: my_topic_table
custom_fields:
my_topic_table:
deleted_at: timestamp
At last, it makes it possible to extend existing plugin schemas... That is, if the plugin planned for it.
What's next?
This feature is so crucial for plugins adoption that it should, in fact, be built-in the symfony plugin system instead of being implemented in each plugin. Users should be able to add a YAML file in the project's config/ directory for each of the schemas provided by the installed plugins, following a naming convention similar to the one I exposed in the sfSimpleForumPlugin.
Of course, this will never work for plugins that use the XML syntax for schemas. Unless we tell the propel tasks to transform every XML schema to YAML, to allow for customization, before transforming them back to XML, which is the only format Propel understands. But to me, the ability to be customized should clearly promote the YAML schema syntax as the only standard for symfony plugins. Future versions of symfony should drop support for the legacy XML schema syntax.
There is another step that could be taken towards standardization... The schema customization exposed in the sfSimpleForumPlugin example uses two different techniques to change table names and add fields. But if the YAML schema syntax was a little different, all this could be done simply through one YAML file. I'm thinking about a new syntax that would mix the current symfony schema syntax and the Doctrine schema syntax. Imagine that the topic table above is defined as follows:
objects:
sfSimpleForumTopic:
tableName: sf_simple_forum_topic
package: plugins.sfSimpleForumPlugin.lib.model
columns:
id:
title: varchar(255)
is_sticked: { type: boolean, default: false }
is_locked: { type: boolean, default: false }
forum_id: { type: integer, foreignClass: sfSimpleForumForum, foreignReference: id, onDelete: cascade }
created_at:
updated_at:
# performance enhancers
latest_post_id: { type: integer, foreignClass: sfSimpleForumPost, foreignReference: id, onDelete: setnull }
user_id: { type: integer, foreignClass: sfGuardUser, foreignReference: id, onDelete: setnull }
stripped_title: varchar(255)
nb_posts: { type: bigint, default: 0 }
nb_views: { type: bigint, default: 0 }
...
If this is automatically merged with user-defined schemas, it would be very easy to change table names or add custom fields, for instance with this file:
objects:
sfSimpleForumTopic:
tableName: my_topic_table
columns:
deleted_at: timestamp
Conclusion
The current YAML syntax for schemas (which I designed myself) was a first step, but it needs to evolve. To be able to fully replace XML schemas, and to allow compatibility between Propel and Doctrine, it is probably necessary to change it - but preserve backward compatibility, of course.
Developing an extension of the sfPropelDatabaseSchema class to implement both the new syntax and the ability to customize schemas is possible, but before getting myself into it, I wonder: Would anybody else benefit from this?

I think some of the YAML and PHP formatting has broken - I understand most of what you've written and it seems like a good idea but it would be useful to see the code to be sure!
@Mike: It should be fixed by now.
[...] Customizing a plugin schema [...]
Won't your start_schema() clash with my start_schema()
[...] my previous post, I exposed a first approach to making a plugin schema customizable. After some coding, I came up [...]