Archive for the 'plugins' Category

It’s Oh So Quiet

It’s so quiet in this blog, because it is closed. Forever. No more post will ever be published, you can’t send any comment, and you can unsubscribe safely from its RSS feed. More than 8,000 unique visitors a month now have time to procrastinate elsewhere.

I have finally admitted that I have better things to do in my free time than contributing to the symfony project. So I can say goodbye to all the following, without regret:

  • Writing a book,
  • Publishing 54 blog posts here and a certain amount in the symfony project blog,
  • Reading 715 comments here and countless emails in the symfony mailing-lists,
  • Following the symfony timeline every day, and reviewing the code contributed to the framework core,
  • Developing, testing, and documenting more than 20 plugins,
  • Giving a few conferences and trainings,
  • And helping newcomers find their way in the symfony ecosystem via IRC, chat, and email.

You can’t imagine how much time all that takes. Well, that’s how much free time I get by leaving symfony completely.

Since summer 2005, my involvement in symfony has been more and more thorough, more and more visceral, and more and more painful. I did my best to push symfony in the direction that I considered to be the right one, but I failed. Symfony used to be simple, well documented, and powerful; today it’s just powerful. Long forgotten are the days where symfony’s motto was “Professional Tools for Lazy Folks”. I see no future in a project where release dates are never satisfied, where new features are released undocumented, where discussions either never start or die without a generally accepted decision, where the community is tolerated only for its praises, and most of all, where the average user is despised.

If you use any of the symfony plugins that I developed and maintained, and if you want to contribute back to their code, you should contact Kris Wallsmith, the new symfony community manager. He’s responsible for these plugins now, and will give developer access at his own discretion. As for me, I may use my commit access for modifications regarding the projects I work on, without further notice.

  • DbFinderPlugin
  • sfAssetsLibraryPlugin
  • sfControlPanelPlugin
  • sfFeed2Plugin
  • sfMediaLibraryPlugin
  • sfModerationPlugin
  • sfPagerNavigationPlugin
  • sfPropelActAsSortableBehaviorPlugin
  • sfPropelAlternativeSchemaPlugin
  • sfPropelSpamTagBehaviorPlugin
  • sfSimpleBlogPlugin
  • sfSimpleCMSPlugin
  • sfSimpleForumPlugin
  • sfSpyPlugin
  • sfStatsPlugin
  • sfUFOPlugin
  • sfUJSPlugin
  • sfWebBrowserPlugin

Redoing the web was an ambitious task. Who knows, I might still manage to do it in the future.

Chapter 10 - Forms

Dealing with the display of form inputs, the validation of a form submission, and all the particular cases of forms is one of the most complex tasks in web development. Luckily, symfony provides a simple interface to a very powerful form sub-framework, and helps you to design and handle forms of any level of complexity in just a few lines of code.

NOTICE: This document is the first draft of a methodology experiment explained earlier in this blog. It documents the sfForm framework found in symfony 1.1, but with some changes in the API and usage. As such, it describes a library that is not yet written (like that) and cannot be used to learn the usage of the current sfForm implementation. It is quite long, so you might prefer to download the Markdown version and read it offline. Being a first draft, this document is a call for comments, both about its structure and its content. And if you are interested in implementing the differences between what this document describes and what is currently implemented in the symfony framework, please contact me.

Read more »

Sorting By Custom Column in the Symfony Admin Generator

Did you ever wish you could sort by a partial column in the admin generator? Using DbFinder and a few lines of code, it is now possible.

The symfony admin generator allows you to select which properties of a model you want to display. You can include foreign key fields, or even a partial field to display pretty much everything you want in the list view. The following example uses this ability to display the name of article authors, based on the fact that the Article model has a many to one relationship to the User model:

# in mymodule/config/generator.yml
generator:
  class:          sfPropelAdminGenerator
  param:
    model_class:  Blog
    theme:        default

list:
  display:        [=title, user, category, _nb_posts, created_at]
  fields: 
    user:         { name: Author }

This generator configuration includes a partial field that counts the number of blog posts for each blog:

// in mymodule/templates/_nb_posts.php
<?php echo $blog->countBlogPosts() ?>


The problem is that only the "True" fields (that is, the ones that correspond to a column in the main table) are sortable. The result is that, with the following example, only the title column is sortable.

With symfony alone, there is no way to make the other columns sortable except overriding the whole _list_th_tabular.php partial in your module, overriding the addSortCriteria() method in the action, and losing the ability to add or remove columns in the future.

Enters DbFinderPlugin. You probably know from this blog that DbFinder offers a very powerful and yet simple way to replace Propel Criteria queries. What you might not know is that the DbFinder plugin bundles a full admin generator theme. It has the exact same features and syntax as the standard symfony admin generator, but it is entirely written with DbFinder queries. And to make this generator theme very usable, it includes the batch_actions extension from symfony 1.1 (that's what allows to display the checkboxes on the left side of the list to perform an action on several records at a time), and the ability to sort by any type of column.

To use the DbFinder admin generator, no need to switch your entire project to DbFinder. Just install the plugin, edit the generator.yml of one of your generated modules, and change the class property from sfPropelAdminGenerator (or sfDoctrineAdminGenerator, if you use Doctrine) to DbFinderAdminGenerator. Refresh the page in your browser, and you should normally see no change. That's good news: despite the fact that all the generator code has been rewritten to work with DbFinder instead of Propel, it is completely backwards compatible.

And once a generated module uses DbFinder, you gain access to the new sort_method option for custom fields:

# in mymodule/config/generator.yml
generator:
  class:          DbFinderAdminGenerator
  param:
    model_class:  Blog
    theme:        default

list:
  display:        [=title, user, category, _nb_posts, created_at]
  fields: 
    user:         { name: Author, sort_method: orderByUsername }
    category:     { sort_method: orderByCategory }
    nb_posts:     { sort_method: orderByNbPosts }

Refresh the list view, and voila, the column headers are now clickable.

Don't click the new links yet: you've defined three methods for custom ordering, and you still have to write them. To do so, you need to create a BlogFinder, which is a finder class specific to the Blog model class. So create a lib/model/BlogFinder.php class with the following content:

// in lib/model/BlogFinder.php
class BlogFinder extends DbFinder
{
  protected $class = 'Blog';
 
  public function orderByUsername($order = 'asc')
  {
    return $this->orderBy('User.Name', $order);
  }

  public function orderByCategory($order = 'asc')
  {
    return $this->orderBy('Category.Name', $order);
  }
 
  public function orderByNbPosts($order = 'asc')
  {
    return $this->
      leftJoin('BlogPost')->
      groupBy('Blog.Id')->
      withColumn('COUNT(BlogPost.Id)', 'nbPosts')->
      orderBy('nbPosts', $order);
  }
}


The finder is smart enough to guess the relationship between the Blog and the User model, as well as the relationship with the Category model, because the YAML schema defines foreign keys between the related tables.

Clear the cache (to allow the autoloading to find the new finder class), refresh your list, and enjoy fully sortable columns.

To finish, here is a small trick to drastically improve your backend performance. Every time the _nb_posts partial is called (and that's once per row in the list), symfony issues a COUNT query. That means that the current configuration will run n+1 queries, n being the number of results per page (typically 20). That's pretty bad for performance. What if you could hydrate an additional column in the main query and use this column in the _nb_posts partial? With DbFinder, that's very easy. Just add a finder_methods setting to your list configuration, as follows:

# in mymodule/config/generator.yml
list:
  display:        [=title, user, category, _nb_posts, created_at]
  fields: 
    user:         { name: Author, sort_method: orderByUsername }
    category:     { sort_method: orderByCategory }
    nb_posts:     { sort_method: orderByNbPosts }
    finder_methods: [withNbPosts]

Symfony executes all the methods defined in the finder_methods before displaying the list. It allows you to define a default ordering, to filter out some records, or, like here, to add custom column to the main query.

Now it's time to create this BlogFinder::withNbPosts() method. Since it contains part of the code of orderByNbPosts(), and that the finder generator executes sort methods at the end of the action, you can reduce the orderByNbPosts() code accordingly:

// in lib/model/BlogFinder.php
public function withNbPosts($order = 'asc')
{
  return $this->
    leftJoin('BlogPost')->
    groupBy('Blog.Id')->
    withColumn('COUNT(BlogPost.Id)', 'nbPosts');
}

public function orderByNbPosts($order = 'asc')
{
  return $this->orderBy('nbPosts', $order);
}


Now the main list query includes the call for the calculated nbPosts column, and you can change the _nb_posts partial to use it:

// in mymodule/templates/_nb_posts.php
<?php echo $blog->getColumn('nbPosts') ?>


Refresh the list view: Ta-da, the result is the same, but using a single query instead of n+1.

So the DbFinder generator offers the same features as the current symfony 1.1 generator, except more. Don't wait until you upgrade your project to symfony 1.2 to enhance your generated modules. Read the DbFinder admin generator documentation, and download the plugin right away.

Document-Driven Development in Practice: Rethinking sfForms

If you've watched or read my presentation on Documentation-Driven Development, you may wonder how to put that new methodology into action. A practical example is often better than a long explanation, so let's see ho to apply it to the new Forms sub-framework introduced by symfony 1.1.

Not DDD

In order to use the new sfForm library, you must either read a book (not yet completely written) or dive into the source code and guess how to use it. To my mind, this is pretty much the contrary of what leads to a large adoption.

The Form framework was designed with power in mind, and reaches this goal very well: you can use it to create forms of any level of complexity, including forms embedding other forms, forms with a variable number of fields, forms split into several steps ("wizards"), etc. It is very much object oriented, so everything can be reused or overridden.

But unfortunately, in order to create a simple form, you need to learn a lot more and write a lot more code than what you used to do in symfony 1.1. The current Forms documentation describes the API and justifies its implementation. It goes very much into the details of each part of the sub-framework, and quite early in the learning process. The result - for me, at least - is that the reader feels overwhelmed by the huge amount of classes, features and options, and dismisses the whole sub-framework for being too complex.

"Let's use that new Form stuff for complex forms and keep the current form helpers and YAML validation for everyday forms", I hear. That's a pity, because once you understand how the new Forms sub-framework works and accept its verbosity, there is no good reason to stick with the old system.

An Ideal sfForm Documentation

I think that a piece of documentation is missing. This piece is probably an introduction to the Form sub-framework.

In symfony 1.0, a single chapter of the book was enough to master forms for most use cases. Even if the new form sub-framework is more powerful than the 1.0 one, it should not be more complicated to learn and use in similar cases. So the sfForms introduction should be short, requiring at most one hour to read it.

After reading this documentation, an average developer should be able to use sfForms in 80% of the cases. That includes at least all the features described in the original Forms chapter of the symfony book:

  • Displaying a form
  • Available form helpers
  • Displaying a model-based form
  • Dealing with Foreign keys
  • Handling a form submission
  • Validating a form
  • Available validators
  • Repopulating a form
  • Complex use cases

The target audience would be people knowing some concepts about symfony, but not yet everything. In fact, they should know what the Chapters 1 to 9 of the symfony guide cover, not more. So some advanced concepts should probably be skipped, or explained only after the fundamental usage is clear.

This introduction should not require additional lookup in the Forms book. That means that it should be self-sufficient. It probably also means not including the justifications of the Forms implementation that you can find in the current Forms book. The reasons why the API was designed the way it is should become obvious at the end of the introduction. Expert customization and rare use cases should probably also be left aside.

The symfony 1.0 documentation introduces concepts and features in a certain order, with a precise purpose: not loading too much information into the reader's mind at a time. In a similar fashion, the forms introduction should be a linear piece of documentation, not a set of articles that you can read in any order with hyperlinks everywhere to break the reading flow.

The forms framework is powerful, but the current form book somehow translates that into length, and verbosity. On the contrary, I think the reader should feel exalted: the documentation should put him in a rush to start using the new forms. So the forms introduction should "tell a story", and gently lead the reader to a point where he feels he can grab the steering wheel and drive the car by himself.

API enhancements

The problem is that explaining the current API takes much longer than a single piece of documentation. That's because of the many options available, because of the many objects to learn, and because even the simplest things (like a list of form controls) look complicated (sfWidgetFormSchema).

There is not much choice to overcome this problem. In order to write a short and readable guide to the forms sub-framework, its API must be adapted. That's right, the API must be changed so that the documentation can be made shorter, and more usable. This is one of the principles of the Documentation-Driven Development methodology.

These API enhancements should be completely backward compatible, so that any existing application using the current sfForms implementation can continue to work seamlessly with the modified implementation. In a way, that qualifies the API enhancements as a simplicity layer on top if the existing code. As a side note, the current Forms book still remains indispensable for advanced usage.

Note that the API enhancements don't need to be implemented before the new documentation is published. The implementation comes second, after the documentation. That's another of the DDD principles: explain first, make it work afterwards. After all, project managers write requirements for web applications before they exist, all the time.

Do As I Do, Not As I Say

Some people are getting sick of reading me criticizing parts of the symfony framework. Well, I'm not criticizing: I'm actively improving.

Rethinking sfForms is a good example for a Documentation-Driven Development. To illustrate this methodology, I'm going to rewrite the Chapter 10 of the symfony book for symfony 1.1. That's right, the current Chapter 10, which describes the "old way" of doing forms, can be rewritten in a similar fashion and serve for symfony 1.1.

But since the current API requires too much explanation to be used, I'm going to introduce the necessary API changes to the sfForms library. I'll create and manage forms in a way slightly different from what the current API allows, to make it simpler to use - and to explain.

When the new Chapter 10 is published here in this very blog, this piece of documentation will be of no use since the features it describes won't be implemented yet. But I know that writing documentation is not enough to convince people (yet), so I will Implement the API changes as a second step to the exercise. As I'm not a very good developer, any help will be welcome during that phase (contact me If you want to give me a hand after the documentation is published).

If everything goes well, the implementation of the API changes will be be released as a symfony plugin - maybe called sfSimpleForms. I hope it can lead more developers to adopt the greatest open-source Forms framework around.

sfPropelFinder becomes DbFinder - Announcing 1.0 release

The sfPropelFinder plugin, which I've told you about a lot lately, has recently been renamed to DbFinder. This emphasizes the fact that the plugin is not Propel-specific anymore, and that you can use it with Doctrine without any change in the API.

Also, I have released a version 0.9 of the plugin today, which marks the 100% coverage of the API with both the Propel and the Doctrine adapters. That's right, now any piece of code using DbFinder will work seamlessly, whatever the ORM you use in symfony.

Take the following code, for instance:

// Look in the Article model
// For objects where the author object related to the article has $nickname for nickname
// Hydrated with related translation in the current culture and category
// And put the result into a pager implementing sfPager for easy display in a web page
$pager = DbFinder::from('Article')->
  where('Author.Nickname', $nickname)->
  with('I18n', 'Category')->
  paginate($currentPage = 1, $maxResultsPerPage = 10);


Getting the same result with either Propel or Doctrine takes considerably more code.

To be honest, the Doctrine coverage is only 99%, since there is still an issue with sfDoctrineFinder::withColumn() when dealing with a calculated column - and this is something that requires Doctrine 1.0 to be fixed. The current Doctrine adapter is based on sfDoctrinePlugin and Doctrine 0.11. But as soon as Doctrine 1.0 is released, withColumn() will be updated to work exactly the same as with Propel.

This release can be considered as a 1.0 beta 1 - meaning I'll probably not add more features before releasing a stable version. I'll work on performance and edge cases if bugs are reported, so you are encouraged to download the plugin, test it, and give me as much feedback as you can.

Eating My Own Dog Food

I spent the last three hours porting my sfSimpleBlog plugin to sfPropelFinder. While it was the occasion to polish the sfPropelFinder API and fix a bug, it was also a great pleasure to replace Propel Peer/Criteria code with finder one.

The blog plugin is up and running, the code is now much cleaner, and as a bonus, the query count has been reduced. If you want to test it, checkout the latest trunk version of the plugin (the plugin release system of the symfony project website doesn't seem to appreciate my PEAR package).

I find sfPropelFinder code to be naturally flowing. It cuts model classes sizes by 50%, it is much more readable, and makes a few custom model methods useless.

See for yourself. Here is the old PluginsfSimpleBlogPostPeer class, holding the methods required to retrieve blog posts, in the previous version:

class PluginsfSimpleBlogPostPeer extends BasesfSimpleBlogPostPeer
{
  public static function getRecentPager($max, $page)
  {
    $pager = new sfPropelPager('sfSimpleBlogPost', $max);
    $c = new Criteria();
    $c->add(self::IS_PUBLISHED, true);
    $c->addDescendingOrderByColumn(self::CREATED_AT);
    $pager->setCriteria($c);
    $pager->setPage($page);
    $pager->setPeerMethod('doSelectJoinAll');
    $pager->init();

    return $pager;
  }

  public static function getRecent($max = 10)
  {
    $c = new Criteria();
    $c->add(self::IS_PUBLISHED, true);
    $c->addDescendingOrderByColumn(self::CREATED_AT);
    $c->setLimit($max);

    return self::doSelectJoinAll($c);
  }

  public static function getTaggedPager($tag, $max, $page)
  {
    $pager = new sfPropelPager('sfSimpleBlogPost', $max);
    $c = new Criteria();
    $c->addJoin(sfSimpleBlogTagPeer::SF_BLOG_POST_ID, self::ID);
    $c->add(sfSimpleBlogTagPeer::TAG, $tag);
    $c->add(self::IS_PUBLISHED, true);
    $c->addDescendingOrderByColumn(self::CREATED_AT);
    $pager->setCriteria($c);
    $pager->setPage($page);
    $pager->setPeerMethod('doSelectJoinAll');
    $pager->init();

    return $pager;
  }

  public static function getTagged($tag, $max)
  {
    $c = new Criteria();
    $c->addJoin(sfSimpleBlogTagPeer::SF_BLOG_POST_ID, self::ID);
    $c->add(sfSimpleBlogTagPeer::TAG, $tag);
    $c->add(self::IS_PUBLISHED, true);
    $c->addDescendingOrderByColumn(self::CREATED_AT);
    $c->setLimit($max);

    return sfSimpleBlogPostPeer::doSelectJoinAll($c);
  }

  public static function retrieveByStrippedTitleAndDate($text, $date, $con = null)
  {
    if ($con === null)
    {
      $con = Propel::getConnection(self::DATABASE_NAME);
    }

    $criteria = new Criteria(sfSimpleBlogPostPeer::DATABASE_NAME);
    $criteria->add(sfSimpleBlogPostPeer::STRIPPED_TITLE, $text);
    if (sfConfig::get('app_sfSimpleBlog_use_date_in_url', false))
    {
      $criteria->add(sfSimpleBlogPostPeer::PUBLISHED_AT, $date);
    }

    $v = sfSimpleBlogPostPeer::doSelect($criteria, $con);

    return !empty($v)> 0 ? $v[0] : null;
  }
}


And here is the revised version. It is no longer a Peer class, but a Finder class extending DbFinder:

class PluginsfSimpleBlogPostFinder extends Dbfinder
{
  protected $class = 'sfSimpleBlogPost';

  public function recent()
  {
    return $this->
      with(sfConfig::get('app_sfSimpleBlog_user_class', 'sfGuardUser'))->
      where('IsPublished', true)->
      orderBy('CreatedAt', 'desc');
  }

  public function tagged($tag)
  {
    return $this->
      join('sfSimpleBlogTag')->
      where('sfSimpleBlogTag.Tag', $tag);
  }

  public function withNbComments()
  {
    return $this->
      leftJoin('sfSimpleBlogComment c')->
      withColumn('COUNT(sf_blog_comment.id)', 'NbComments')->
      where('c.IsModerated', false)->
      groupBy('c.SfBlogPostId');
  }

  public function findByStrippedTitleAndDate($text, $date)
  {
    $this->where('StrippedTitle', $text);
    if (sfConfig::get('app_sfSimpleBlog_use_date_in_url', false))
    {
      $this->where('PublishedAt', $date);
    }

    return $this->findOne();
  }
}


For those who followed my previous posts, you probably understand that the only thing that prevents sfSimpleBlog from working with Doctrine is the advance in the implementation of sfDoctrineFinder class. Once that is finished - and I'm progressing quite fast - sfSimpleBlogPlugin will be the first true ORM agnostic plugin.

Admin Generator compatible with Propel and Doctrine

Just a quick note to mention a recent addition I made to the sfPropelFinderPlugin. It now features an admin generator theme, identical in functionality to the Propel and Doctrine admin generators, except... It uses DbFinder queries instead of Criteria or Doctrine_Query calls. See more in the Generator README file.

This has two implications:

  • Modules based on this generator are easier to customize, especially if you need to override methods of the action class. Instead of dealing with complicated Criterion conditions, you manipulate finder objects, with all the ease of use it implies.
  • Modules based on this generator are ORM agnostic, meaning they work both with Propel and Doctrine (actually, this is not entirely true, since sfDoctrineFinder doesn't implement all the features required by the DbFinder generator yet... but it will soon be true).

It makes the writing of ORM-agnostic plugins possible, especially for plugins like sfSimpleCMSPlugin or sfSimpleBlogPlugin who feature backend modules generated by symfony.

That's decided, the next version of the plugins I maintain will use DbFinder!

Reconciling Propel and Doctrine

Would you like to use Propel plugins in your Doctrine application, or vice-versa? For instance, our recent sfAssetsLibraryPlugin provides a great media management utility, but it requires Propel to use it. Thanks to recent progress on the sfPropelFinder plugin, you may soon be able to use it with doctrine.

Getting Abstract

My recent work on sfPropelFinder aimed at providing Propel with an easier requesting API than the current Criteria API. In order to do that, I took inspiration from other ORMs who provide fluid interfaces to the database layer (including Doctrine, Rails has_finder plugin and SQL Alchemy).

The resulting API facilitates object retrieval and modification with Propel - as much as PHP and the underlying Propel API allow it:

# Listing 1 - Finding Propel objects with sfPropelFinder
$article = sfPropelFinder::from('Article')->
  where('Title', 'like', '%foo')->
  leftJoin('Author')->
  where('Author.Name', 'John Doe')->
  orderBy('CreatedAt', 'desc')->
  findOne();


This will look familiar to Doctrine users, since the Doctrine Query API works in a similar way:

# Listing 2 - Finding Doctrine objects with Doctrine_Query
$article = Doctrine_Query::create()->
  from('Article a')->
  where('a.title like ?', '%foo')->
  leftJoin('a.Author u')->
  where('u.name = ?', 'John Doe')->
  orderby('a.created_at DESC')->
  fetchOne();


The APIs are so similar that it is not very difficult to translate sfPropelFinder method calls to Doctrine_Query calls. So I tested this idea and created a sfDoctrineFinder class:

# Listing 3 - Finding Doctrine objects with sfDoctrineFinder
$article = sfDoctrineFinder::from('Article')->
  where('Title', 'like', '%foo')->
  leftJoin('Author')->
  where('Author.Name', 'John Doe')->
  orderBy('CreatedAt', 'desc')->
  findOne();


Note: sfDoctrineFinder::leftJoin() is not yet implemented at the time of writing, but I keep the same code as above to make things clearer.

Internally, the code from Listing 3 is translated to the one from Listing 2. But it looks so identical to the one of Listing 1 that you could easily imagine an abstraction of the ORM layer. That's what I did by creating DbFinder, a class helper that instanciates either a sfPropelFinder, or a sfDoctrineFinder, based on the nature of the model requested.

# Listing 4 - Finding Model objects with DbFinder
$article = DbFinder::from('Article')->
  where('Title', 'like', '%foo')->
  leftJoin('Author')->
  where('Author.Name', 'John Doe')->
  orderBy('CreatedAt', 'desc')->
  findOne();


So the code from Listing 4 will work whether the Article class extends Doctrine_Record or BaseObject - in other terms, whether the underlying ORM is Doctrine or Propel. DbFinder provides a unified API for communicating with both ORMs.

What's The Use?

I hear the voices of Doctrine users, shouting something like: "DbFinder is much less powerful than Doctrine_Query, so why should I use it?". The answer is 'You should not'. If your ORM is Doctrine, stick to Doctrine_Query and don't use DbFinder at all. Unless you want to switch to Propel sometime in your project, but that's very unlikely.

I also hear voices saying: "Both Doctrine and Propel are abstraction layers. It doesn't make sense to build a layer to abstract abstraction layers". Those voices say right, and being abstract for the sake of being abstract is useless and too Java-like for us RAD fans.

So why bother to develop a Doctrine adapter for DbFinder if it doesn't make sense and if Doctrine users won't use it? Because it allows to develop ORM agnostic plugins. Imagine a plugin where all database queries are made by way of DbFinder. Since both Propel and Doctrine objects extend generated "Base" objects, and share a common API (getters and setters for columns and related objects), this is possible.

That means that plugins like sfSimpleForumPlugin, sfSimpleCMSPlugin, or sfGuardPlugin could have one single version working in both ORMs. No more code duplication between a Propel and an Doctrine version, no more time lost to backport modifications from one version to the other. And most important: a larger user base for all plugins, since both Doctrine users and Propel users can use them.

As a bonus, sfPropelFinder is already a compatibility layer between Propel 1.2 and Propel 1.3. Since you don't need any custom hydration with sfPropelFinder, and since it manipulates resultsets in a different way whether you are on Propel 1.2 or Propel 1.3, all the code written with sfPropelFinder already works in both versions of the ORM.

Where Do I Download This Great Thing?

Don't be in a rush, the Doctrine adapter for DbFinder is far from being finished yet. Only a small share of the sfPropelFinder features are ported to sfDoctrineFinder. And there is no available plugin using DbFinder for now. But if you write your Propel plugins with DbFinder calls starting from now (which you should, since it's so much easier), there is a good chance that you will need little to no effort to make them work with Doctrine once sfDoctrineFinder is finished.

Also, if you are a Doctrine guru and want to give me a hand to write the Doctrine adapter to DbFinder, please send me an email and we'll arrange something.

Comparing Propel, Doctrine and sfPropelFinder

When it comes to ORMs, it's all a matter of preference. Is it, really? This post compares side-by-side the code required to perform some simple operations with three OO database requesting API. The purpose is to demonstrate that productivity, and not only style, can vary a lot depending on the ORM you choose.

There are not many robust Object Relational Mapping layers in PHP5. I'll consider two of them:

  • Propel is an ORM that "allows you to access your database using a set of objects, providing a simple API for storing and retrieving data. Propel allows you, the web application developer, to work with databases in the same way you work with other classes and objects in PHP."

  • Doctrine is an ORM that "sits on top of a powerful PHP DBAL (database abstraction layer). One of its key features is the ability to optionally write database queries in an OO (object oriented) SQL-dialect called DQL inspired by Hibernates HQL. This provides developers with a powerful alternative to SQL that maintains a maximum of flexibility without requiring needless code duplication."

I will also consider an additional component to Propel named sfPropelFinder. It "provides an easy API for finding Propel objects - that is, easier than the Peer methods and the Criteria stuff". sfPropelFinder is a symfony plugin, but it can be used with Propel alone.

For the examples, I'll use the classic Article/Comment model.

Disclaimer: Being the author of sfPropelFinder, you may think that I chose examples that make it look better. To avoid this bias, I wrote a lot of examples, including some where this plugin does not perform very well. Still, if the sfPropelFinder comparison with the two other ORMs is not objective, the comparison between Propel and Doctrine is quite so.

Scope

This comparison will only focus on the API - I voluntarily leave the performance benchmarks to whoever wants to do it. But I think the gross performance comparison probably looks like:

Slowest    sfPropelFinder + Propel 1.2
|          Propel 1.2
|          Doctrine 0.11
|          sfPropelFinder + Propel 1.3
Fastest    Propel 1.3

As for the features, it is hard to give an objective comparison without getting too much in the details. If you wonder if a particular ORM does something that another can't do, post a comment about it and I'll try to give you an honest answer.

Bear in mind that sfPropelFinder is very young, that Doctrine is quite young, and that Propel has a longer history and is the most stable and mature of all three.

Retrieving an article by its primary key

// Propel
$article = ArticlePeer::retrieveByPk(123);
// Doctrine
$article = Doctrine::getTable('Article')->find(123);
// sfPropelFinder
$article = sfPropelFinder::from('Article')->findPk(123);


Retrieving the comments related to an article

// Propel
$comments = $article->getComments();
// Doctrine
$comments = $article->Comments;
// sfPropelFinder
$comments = $article->getComments(); // no change - use Propel


Retrieving an article from its title

// Propel
$c = new Criteria();
$c->add(ArticlePeer::TITLE, 'FooBar');
$article = ArticlePeer::doSelectOne($c);

// Doctrine
$article = Doctrine_Query::create()->
  from('Article a')->
  where('a.title = ?', array('FooBar'))->
  fetchOne();
// Doctrine (faster)
$article = Doctrine::getTable('Article')->
  findOneByTitle('FooBar');

// sfPropelFinder
$article = sfPropelFinder::from('Article')->
  where('Title', 'FooBar')->
  findOne();
// sfPropelFinder (faster)
$article = sfPropelFinder::from('Article')->
  findOneByTitle('FooBar');


Retrieving the latest 5 articles

// Propel
$c = new Criteria();
$c->addDescendingOrderByColumn(ArticlePeer::PUBLISHED_AT);
$c->setLimit(5);
$articles = ArticlePeer::doSelect($c);

// Doctrine
$articles = Doctrine_Query::create()->
  from('Article a')->
  orderby('a.published_at DESC')->
  limit(5)->
  execute();

// sfPropelFinder
$articles = sfPropelFinder::from('Article')->
  orderBy('PublishedAt', 'desc')->
  find(5);


Retrieving the last 5 comments related to an article

// Propel
$c = new Criteria();
$c->addDescendingOrderByColumn(CommentPeer::PUBLISHED_AT);
$c->setLimit(5);
$comments = $article->getComments($c);

// Doctrine
$comments = Doctrine_Query::create()->
  from('Comment c')->
  where('c.article_id = ?', array($article->getId()))->
  orderby('c.published_at DESC')->
  limit(5)->
  execute();

// sfPropelFinder
$comments = sfPropelFinder::from('Comment')->
  relatedTo($article)->
  orderBy('PublishedAt', 'desc')->
  find(5);


Retrieving the last comment related to an article

// Propel
$c = new Criteria();
$c->addDescendingOrderByColumn(CommentPeer::PUBLISHED_AT);
$c->add(CommentPeer::ARTICLE_ID, $article->getId());
$comment = CommentPeer::doSelectOne($c);

// Doctrine
$comments = Doctrine_Query::create()->
  from('Comment c')->
  where('c.article_id = ?', array($article->getId()))->
  orderby('c.published_at DESC')->
  fetchOne();

// sfPropelFinder
$comments = sfPropelFinder::from('Comment')->
  relatedTo($article)->
  findLast();


Retrieving articles based on a word appearing in the title or the summary

// Propel
$c = new Criteria();
$cton1 = $c->getNewCriterion(ArticlePeer::TITLE, '%FooBar%', Criteria::LIKE);
$cton2 = $c->getNewCriterion(ArticlePeer::SUMMARY, '%FooBar%', Criteria::LIKE);
$cton1->addOr($cton2);
$c->add($cton1);
$articles = ArticlePeer::doSelect($c);

// Doctrine
$article = Doctrine_Query::create()->
  from('Article a')->
  where('a.title like ? OR a.summary like ?', array('%FooBar%', '%FooBar%'))->
  execute();

// sfPropelFinder
$article = sfPropelFinder::from('Article')->
  where('Title', 'like', '%FooBar%')->
  _or('Summary', 'like', '%FooBar%')->
  find();


Retrieving articles based on a complex AND/OR clause

// Articles having name or summary like %FooBar% and published between $begin and $end

// Propel
$c = new Criteria();
$cton1 = $c->getNewCriterion(ArticlePeer::TITLE, '%FooBar%', Criteria::LIKE);
$cton1 = $c->getNewCriterion(ArticlePeer::SUMMARY, '%FooBar%', Criteria::LIKE);
$cton1->addOr($cton2);
$c->add($cton1);
$c->add(ArticlePeer::PUBLISHED_AT, $begin, Criteria::GREATER_THAN);
$c->addAnd(ArticlePeer::PUBLISHED_AT, $end, Criteria::LESS_THAN);
$article = ArticlePeer::doSelect($c);

// Doctrine
$article = Doctrine_Query::create()->
  from('Article a')->
  where('(a.title like ? OR a.summary like ?) and (article.published_at> ? and article.published_at> ?)', array('%FooBar%', '%FooBar%', $begin, $end))->
  execute();

// sfPropelFinder
$article = sfPropelFinder::from('Article')->
    where('Title', 'like', '%FooBar%', 'cond1')->
    where('Summary', 'like', '%FooBar%', 'cond2')->
   combine(array('cond1', 'cond2'), 'or', 'cond3')->
    where('PublishedAt', '>', $begin, 'cond4')->
    where('PublishedAt', '<', $end, 'cond5')->
   combine(array('cond4', 'cond5'), 'and', 'cond6')->
  combine(array('cond3', 'cond6'), 'and')->
  find();


Retrieving articles authored by someone

// Propel
$c = new Criteria();
$c->addJoin(ArticlePeer::AUTHOR_ID, AuthorPeer::ID);
$c->add(AuthorPeer::NAME, 'John Doe');
$articles = ArticlePeer::doSelect($c);

// Doctrine
$article = Doctrine_Query::create()->
  from('Article a')->
  leftJoin('a.Author b')->
  where('b.name = ?', array('John Doe'))->
  execute();

// sfPropelFinder
$article = sfPropelFinder::from('Article')->
  where('Author.Name', 'John Doe')-> // Guesses the join from the schema
  find();


Retrieving articles authored by people of a certain group

// Propel
$c = new Criteria();
$c->addJoin(ArticlePeer::AUTHOR_ID, AuthorPeer::ID);
$c->addJoin(AuthorPeer::GROUP_ID, GroupPeer::ID);
$c->add(GroupPeer::NAME, 'The Foos');
$articles = ArticlePeer::doSelect($c);

// Doctrine
$article = Doctrine_Query::create()->
  from('Article a')->
  leftJoin('a.Author b')->
  leftJoin('b.Group c')->
  where('c.name = ?', array('The Foos'))->
  execute();

// sfPropelFinder
$article = sfPropelFinder::from('Article')->
  join('Author')->
  where('Group.Name', 'The Foos')-> // Guesses the Group join from the schema
  find();


Retrieving all articles and hydrating their category object in the same query

// Propel
$c = new Criteria();
$articles = ArticlePeer::doSelectJoinCategory($c);

// Doctrine
$article = Doctrine_Query::create()->
  from('Article a')->
  leftJoin('a.Category c')->
  execute();

// sfPropelFinder
$article = sfPropelFinder::from('Article')->
  with('Category')->
  find();


Retrieving an article and its category by the article primary key

// Propel
$c = new Criteria();
$c->add(ArticlePeer::ID, 123);
$c->setLimit(1);
$articles = ArticlePeer::doSelectJoinCategory($c);
$article = isset($articles[0]) ? $articles[0] : null;

// Doctrine
$article = Doctrine_Query::create()->
  from('Article a')->
  leftJoin('a.Category c')->
  where('a.id = ?', array(123))->
  fetchOne();

// sfPropelFinder
$article = sfPropelFinder::from('Article')->
  with('Category')->
  findPk(123);


Retrieving articles and hydrating their author object and the author group

// Propel
// Impossible do to it simply - need for a custom hydration method (approx 40 LOC)

// Doctrine
$article = Doctrine_Query::create()->
  from('Article a')->
  leftJoin('a.Author b')->
  leftJoin('b.Group c')->
  where('a.id = ?', array(123))->
  fetchOne();

// sfPropelFinder
$article = sfPropelFinder::from('Article')->
  with('Category', 'Group')->
  findPk(123);


Conclusion

That's a lot of queries. And I didn't mention many-to-many relations, addition of columns, behaviors, update/delete queries, count queries, or pagers. But overall, my conclusion after writing these examples is:

  • Propel is the most verbose ORM of all three
  • sfPropelFinder is the most magic of all three
  • sfPropelFinder and Doctrine are the fastest to write, depending on the cases
  • Some limits of Propel are very frustrating (limited doSelectJoinXXX(), Criterions, custom hydration)
  • Propel and sfPropelFinder will never beat DQL for complex queries

Finally, if you are wondering which ORM to choose for your next symfony project, make sure that you put the productivity in the balance.

sfPropelFinder: Now With I18n, Pagination and Update Queries

Development goes on at a decent pace on the sfPropelFinderPlugin. Recent additions make it even more suitable for your needs. It should cover about 80% of the common use cases, so you'd better try it fast.

Compatible With I18n

Projects using symfony's internationalization (i18n) layer to store various versions of a record in the database based on the user culture can also use sfPropelFinder. As an alternative to doSelectWithI18n(), you can use the plugin's native with() method to hydrate the related i18n objects.

For instance, if you have an Article class with i18n for text columns:

article:
  id:         ~
  author:     varchar(255)
  created_at: ~
article_i18n:
  content:     varchar(255)

This structure allows you to define several values for the content column, directly on the Article object:

$article = new Article();
$article->setAuthor('John');
$article->setCulture('en');
$article->setContent('english content');
$article->setCulture('fr');
$article->setContent('contenu français');
$article->save();


You probably know how to request Articles and their i18n version in a single query with doSelectWithI18n():

$c = new Criteria();
$c->add(ArticlePeer::AUTHOR, 'John');
$articles = ArticlePeer::doSelectWithI18n($c);


The sfPropelFinder offers an alternative way to do this:

$articles = sfPropelFinder::from('Article')->
  where('Author', 'John')->
  with('I18n')->
  find();


Not ony is it faster to write, it also allows you to use i18n with other 'with()' hydratation clauses, or to retrieve a single object, which is impossible with the generated i18n methods:

$article = sfPropelFinder::from('Article')->
  where('Author', 'John')->
  with('I18n', 'Category')->
  findOne();


Just like doSelectWithI18N(), with('I18n') will select the version of the i18n content based on the current user culture. If you want to force the retrieval of a particular culture, you can use the withI18n($culture) synonym:

$article = sfPropelFinder::from('Article')->
  withI18n('en')->
  findOne();


with('I18n') and withI18n() without further arguments are synonyms. But, as I always forget whether the final 'N' of 'I18n' must be lower or uppercase, the with() version is permissive and will work with 'i18n', 'i18N', 'I18n' and 'i18N'. No more silly mistakes.

Update Queries

Thanks to a patch from an early adopter of the plugin named jug, sfPropelFinder now allows you to update several records in a row. This is something that required the use of BasePeer::doUpdate() in the past, now it is as simple as an associative array defining the columns to change:

$article = sfPropelFinder::from('Article')->
  where('Author', 'John')->
  set(array('IsRead' => true));


The set() method is a termination method similar to find(), count() or delete(), meaning that it doesn't return an sfPropelFinder object.

Beware that such a query will not trigger any of the behaviors registered on the save() method - it uses BasePeer::doUpdate() in the background. You can choose to force a record-by-record update by setting the second parameter of the set() method to true.

$article = sfPropelFinder::from('Article')->
  where('Author', 'John')->
  set(array('IsRead' => true), true);


Finder From A List Of Records

If, for any reason, you already have an array of Propel objects that you want to filter further, you can initialize a finder with this array. All the finder methods will work normally, based on the list.

$comments = $article->getComments();
$comments = sfPropelFinder::from($comments)->
  where('content', 'like', '%foo%')->
  orderBy('Author')->
  findLast();


This will result in a single query using IN () to limit the result to the original array.

Paginating A List Of Results

Using sfPropelPager is easy, only it takes a couple lines. Plus, it uses a Criteria object, and the purpose of the sfPropelFinder is to avoid using Criteria as much as possible. So how do you get a paginated list, i.e. a pager object, based on a finder? Simply call the paginate($page, $maxPerPage) termination method.

$pager = sfPropelFinder::from('Article')->
  with('I18n', 'Category')->
  where('Author', 'John')->
  paginate(1, 10);
// Use the usual pager methods
echo $pager->getNbResults();
foreach ($pager->getResults() as $article)
{
  echo $article->getTitle();
}


The setPeerMethod() is useless, since you can set the pager to hydrate related tables and columns with the with() method before paginating it.

Conclusion

It's only the beginning. sfPropelPager covers a large share of the object model retrieval use case, and the objective is to reduce the need for Criteria to less than 1% of the cases.

If you're interested in this plugin, make sure you read the previous posts on this blog:

Next Page »