unaneem.com : a community website built on CakePHP with extensive use of AJAX

This article is also available in the following languages:
By Kainchi

On April 2007 a friend and I started a project we named "unaneem". The purpose of this project was to develop a community website which helps people to share and organize events.We decided to use only open source technologies. As our core application library we used CakePHP.

The project steps over time were :

  1. April 2007 to June2007 : Functional studies
  2. June 2007 to July 2007 : Templates design
  3. July 2007 to September 2007 : Database development
  4. September 2007 to October 2008 (now): Application development
  5. October 2008 : Production deployment : unaneem goes online !
  6. October 2008 to 2009 (now): Beta version test and debug

The application uses CakePHP as its core PHP library, Prototype as its core AJAX library and many open source external PHP and JS libraries :

PHP Libraries :

  1. PHPThumb
  2. PHPBrowscap
  3. KCaptcha
  4. ...

JS Libraries :

  1. Scriptaculous
  2. SWFUpload
  3. Lightbox
  4. ...

You can see the application current version at http://www.unaneem.com/

Motivation

As a computer engineer, a little bit geeky I confess, I wanted to create my own business. I was very interested in the opportunities given by the web technologies. In 2007, I felt that I should take the opportunity to create a web 2.0 (3.0, 4.0, 2.265.34, how marketing is that !) website. I came up with the idea of a community website were people would be able to share their favorite places and events and communicate in order to hang out with friends.

I talked about this project with a friend of mine and he decided to build it with me. We began with a white paper sheet with nothing more than our fingers, keyboards and imagination. As we had nothing (you know the kind of things like money that makes it a lot easier...) we decided to work only with open source technologies and get the best of it.

2 years later we've got a running web application that matches our initial vision. From nothing, from scratch to the real thing, that's where my motivation is !

Why CakePHP ?

Our main frameworks :

We decided to rely on php/mysql server-side technologies and javascript with AJAX integration for client-side technologies. As we were searching for frameworks we found CakePHP and were seduced by it's "orthodox" MVC (Model View Controller) approach and AJAX prospects.

The softwares/tools we use with CakePHP

We are using free (open source) softwares for development,design and maintenance of our CakePHP application:

Server softwares :

Database design, integration and maintenance :

Programming tools:

Web browsers (for cross-compatibility purpose) and browser debugging tools:

Graphic design and optimization :

These are the main tools/softwares we are using on this project.

The technologies we integrated with CakePHP

We used many external libraries for specific purpose that should answer our functionnal needs. And we found great snippets, helpers, components, controllers and tutorials on the bakery that eased integration.

The PHP libraries we use with CakePHP:

The Javascript libraries we use with CakePHP:

All these libraries are licensed as open source (GPL,BSD,MIT,etc...) and with a little bit of JS/PHP/MySQL handcrafting they can be used in a professional context, show great performances and functional value.

Steps toward production of a CakePHP application

  1. Functional studies

    Our first task was to assess all the functionalities the different user categories (visitors, members, etc...) would access throughout the application. We created a functionnal chart which was in a way a summary of what our website would be. For this task we used white paper sheets and pens, well it had to begin somewhere.

  2. Templates design

    Then, we decided to design templates of the various website pages. For this purpose we used the GIMP to design various graphic objects and Notepad++ for html/css programming.

  3. Database development

    We created the database according to our functional chart with dbdesigner and tested it with PMA (PhpMyAdmin).

  4. Application development

    We began learning how to use CakePHP and developping the application (with notepad++ the greatest web development tool in the world i guess). Our goal before deployment was to gather as many functionnal needs as possible to provide a service that would attract people on the website and keep them coming to help us with the beta testing.

  5. Production deployment : unaneem goes online

    We deployed unaneem (http://www.unaneem.com/) and asked to our friends and relatives to sign up on October 2008.

  6. Beta version test and debug

    We are currently testing unaneem with 170 users (if you want to contribute anyone can sign up). Our users can use a website feature called "bug report" to send reports about enhancements or bugs to be corrected. The point is to have the smoothest result in terms of navigation and performances.

Challenges we had to face and overcome using CakePHP

  1. full UTF-8 support

    Our application is intended to be international and therefore to be UTF-8 compliant to handle all kind of characters (japanese, arabic, russian, etc...). We had to set every application layers to UTF-8 (database, php core: CakePHP, html documents, javascript functions and core : Prototype).CakePHP supports natively UTF-8 data input and outputs. However some basic PHP functions are not relevant with UTF-8 characters. We had to build a custom CakePHP helper and a custom CakePHP component to handle what should be basic text manipulation.

    For instance, the "substr" PHP method is not compliant with UTF-8. An UTF-8 non latin character is multibytes. It occurs that the "substr" method doesn't count characters but bytes. When you get a substring from an UTF-8 multibyte string, a multibyte character can be cropped in the middle and return a false multibyte character code. The output of the "substr" method will be a string ending with a multibyte string error. We created a custom method to handle this kind of basic manipulation which prevents this kind of errors.

    Here's the code for this custom substring UTF-8 method (needs mb_string to be loaded):


    function substring($string = '',$limit = 10,$suffix='...',$from = 0){
        if(isset($string)&&is_string($string)){
            if(mb_strlen($string,'UTF-8')>$limit){
                //removing characters according to limit and UTF-8 encoding
                $string = preg_replace('#^(?:[\x00-\x7F]|[\xC0-\xFF][\x80-\xBF]+){0,'.$from.'}'.'((?:[\x00-\x7F]|[\xC0-\xFF][\x80-\xBF]+){0,'.$limit.'}).*#s','$1', $string);
                return $string.$suffix;
            }else{
                return $string;
            }
        }else{
            return null;
        }
    }
  2. Dynamic sitemaps generation

    As I wrote before, our application is intended to be international and is mutlilingual. We have multiple host aliases based on various locales to set the right language and localization for our users (en-us : english for united states,en-en : english for england,fr-fr : french for france,es-es : spanish for spain,...). Each document of our site must be indexed for these various locales.

    For instance the home page needs to be indexed by search engines as:

    The application translations are saved in a database with corresponding locale codes. When a locale is detected in the typed url, the application switches to the right language which is set into a view variable.

    For indexing purpose we had to have one sitemap per locale. And the sitemap has to be located at the website root (example: http://www.mysite.com/sitemap.xml). This recommendation is made by http://www.sitemaps.org/. We had two problems occuring. First, to generate sitemaps for each locales with a cron job every week and the second problem was to make all the sitemaps accessible with a root-level path.

    • First problem: Creating a cronjob using CakePHP controllers and models to fetch the language database and the main data database to create sitemaps for each locales in a webroot folder.

    To create our CakePHP cron we based our work on this great bakery article:

    http://bakery.cakephp.org/articles/view/calling-controller-actions-from-cron-and-the-command-line

    We built a controller which could only be called by the CakePHP cron dispatcher with a method that would generate the sitemaps.

    • Second problem: Make the sitemaps accessible from a root level path.

    The generated sitemaps are located in folders and subfolders of the webroot directory. This means that they would be accessible by typing "mysite.com/folder/subfoled/sitemap.xml". But they should be accessible by typing "mysite.com/sitemap.xml" instead.

    To solve this problem we created a sitemaps controller which could read and render the requested sitemap. We used one of the great CakePHP ability which is custom routes.

    When we type : http://www.mysite.com/sitemap_en-us_index.xml, the custom route calls the sitemaps controller with index action and parse the string "sitemap_en-us_index" to put in the $this->params['pass'] the locale (ie: "en-us") and the kind of file (ie: "index").

    The custom route which is located in the /config/routes.php file looks like :

    //For sitemaps : specific pattern connects to sitemaps controller
    $Route->connect('/sitemap_((index)|([0-9]+)).xml', array('controller' => 'sitemaps', 'action' => 'index'));
    $Route->connect('/sitemap_([a-z]{2}-[a-z]{2}){1}_((index)|([0-9]+)).xml', array('controller' => 'sitemaps', 'action' => 'index'));
  3. Native CakePHP 1.1 poor join tables management

    Another problem was directly linked to CakePHP HABTM (hasAndBelongsToMany) management. For instance I have a join table "members_messages" between the "members" table and the "messages" table and I want to put a flag on the unread messages. This means that I'll have a field ("unread") on the join table. The problem we had with CakePHP native functions was to manipulate these kind of fields. An other concern was adding, deleting and finding join relationships with extra join table fields.

    To solve this problem, I found a great Bakery article http://bakery.cakephp.org/articles/view/add-delete-habtm-behavior. It occured to me that this solution was not solving all my habtm issues because the add/delete methods were resetting the extra fields values. I created custom methods that would perform CRUD operations over join tables preserving join tables extra fields.

    In app_model.php in the app root I added these methods that I called "smartHABTM..." :

    Model Class:

    <?php class AppModel extends Model{
        
    /**
        * Smart!!! Find 
        * fetch habtm relationship and returns full habtm table data
        *
        * @param string $assoc
        * @param int $id
        * @return array
        **/
        
    function smartHabtmFind($assoc$id) {

            
    //smart bind
            
    $className $this->smartHabtmBind($assoc);

            if(
    $className===false){
                return array();
            }else{
                
    // temp holder for model-sensitive params
                
    $tmp_recursive $this->recursive;
                
    $tmp_cacheQueries $this->cacheQueries;

                
    $this->recursive 1;
                
    $this->cacheQueries false;

                
    $this->expects(array($className));

                
    $data $this->read(array($this->name.'.'.$this->primaryKey),$id);

                
    $this->recursive $tmp_recursive;
                
    $this->cacheQueries $tmp_cacheQueries;

                if(
    $this->smartHabtmUnbind($assoc)===false){
                    return array();
                }else{
                    if(isset(
    $data[$className])){
                        return 
    $data[$className];
                    }else{
                        return array();
                    }
                }
            }
        }

        
    /**
        * Smart!!! Add 
        * Add a Smart!!! HABTM association
        *
        * @param string $assoc
        * @param int $id
        * @param mixed $assoc_ids
        * @return boolean
        **/
        
    function smartHabtmAdd($assoc$id$assoc_ids,$extra = array()){

            if(!
    is_array($assoc_ids)){
                
    $assoc_ids = array($assoc_ids);
            }

            if(isset(
    $this->hasAndBelongsToMany[$assoc])){

                
    //smart bind
                
    $className $this->smartHabtmBind($assoc);

                if(
    $className===false){
                    return 
    false;
                }else{

                    
    $data $this->smartHabtmFind($assoc,$id);

                    
    $new_data $data;

                    foreach(
    $assoc_ids as $assoc_id){

                        
    $assoc_data $this->__buildRecordSet($className,$assoc,$id,$assoc_id,$extra);

                        
    $add true;
                        foreach(
    $new_data as &$record){
                            if(
    $add&&isset($record[$this->hasAndBelongsToMany[$assoc]['foreignKey']])&&isset($record[$this->hasAndBelongsToMany[$assoc]['associationForeignKey']])&&isset($assoc_data[$this->hasAndBelongsToMany[$assoc]['foreignKey']])&&isset($assoc_data[$this->hasAndBelongsToMany[$assoc]['associationForeignKey']])&&($record[$this->hasAndBelongsToMany[$assoc]['foreignKey']]==$assoc_data[$this->hasAndBelongsToMany[$assoc]['foreignKey']])&&($record[$this->hasAndBelongsToMany[$assoc]['associationForeignKey']]==$assoc_data[$this->hasAndBelongsToMany[$assoc]['associationForeignKey']])){
                                
    $add false;
                                foreach(
    $record as $key => &$field){
                                    foreach(
    $assoc_data as $assoc_key => $assoc_field){
                                        if(
    $key===$assoc_key){
                                            
    $field $assoc_field;
                                        }
                                    }
                                }
                                break;
                            }
                        }
                        if(
    $add){
                            
    $new_data[] = $assoc_data;
                        }

                    }

                    if(isset(
    $new_data)&&!empty($new_data)){

                        
    $new_data Set::diff($new_data,$data);

                        if(empty(
    $new_data)){
                            
    $this->smartHabtmUnbind($assoc);
                            return 
    true;
                        }else{
                            
    $tmp_cacheQueries $this->$className->cacheQueries
                            
    $this->$className->cacheQueries false;
                            foreach(
    $new_data as $save){
                                if(
    $this->$className->save(array($className => $save))){
                                    
    $this->$className->id false;
                                    continue;
                                }else{
                                    
    $this->$className->cacheQueries $tmp_cacheQueries;
                                    
    $this->smartHabtmUnbind($assoc);
                                    return 
    false;
                                }
                            }
                            
    $this->$className->cacheQueries $tmp_cacheQueries;
                            
    $this->smartHabtmUnbind($assoc);
                            return 
    true;
                        }
                    }else{
                        
    $this->smartHabtmUnbind($assoc);
                        return 
    false;
                    }

                }

            }else{
                return 
    false;
            }
        }

        
    /**
        * Smart!!! Delete 
        * Smart!!! Delete of an HABTM association
        *
        * @param string $assoc
        * @param int $id
        * @param mixed $assoc_ids
        * @return boolean
        */
        
    function smartHabtmDelete($assoc$id$assoc_ids) {

            if(!
    is_array($assoc_ids)){
                
    $assoc_ids = array($assoc_ids);
            }

            if(isset(
    $this->hasAndBelongsToMany[$assoc])){

                
    //smart bind
                
    $className $this->smartHabtmBind($assoc);

                if(
    $className===false){
                    return 
    false;
                }else{

                    
    $delete = array();
                    
    $data $this->smartHabtmFind($assoc,$id);

                    foreach(
    $assoc_ids as $assoc_id){

                        
    $assoc_data $this->__buildRecordSet($className,$assoc,$id,$assoc_id);

                        foreach(
    $data as &$record){
                            if(isset(
    $record[$this->hasAndBelongsToMany[$assoc]['foreignKey']])&&isset($record[$this->hasAndBelongsToMany[$assoc]['associationForeignKey']])&&isset($assoc_data[$this->hasAndBelongsToMany[$assoc]['foreignKey']])&&isset($assoc_data[$this->hasAndBelongsToMany[$assoc]['associationForeignKey']])&&($record[$this->hasAndBelongsToMany[$assoc]['foreignKey']]==$assoc_data[$this->hasAndBelongsToMany[$assoc]['foreignKey']])&&($record[$this->hasAndBelongsToMany[$assoc]['associationForeignKey']]==$assoc_data[$this->hasAndBelongsToMany[$assoc]['associationForeignKey']])){
                                
    $delete[] = $record[$this->$className->primaryKey];
                            }
                        }
                    }

                    if(empty(
    $delete)){
                        
    $this->smartHabtmUnbind($assoc);
                        return 
    false;
                    }else{
                        
    $tmp_cacheQueries $this->$className->cacheQueries
                        
    $this->$className->cacheQueries false;
                        foreach(
    $delete as $del){
                            if(
    $this->$className->del($del)){
                                continue;
                            }else{
                                
    $this->$className->cacheQueries $tmp_cacheQueries;
                                
    $this->smartHabtmUnbind($assoc);
                                return 
    false;
                            }
                        }
                        
    $this->$className->cacheQueries $tmp_cacheQueries;
                        
    $this->smartHabtmUnbind($assoc);
                        return 
    true;
                    }
                }
            }else{
                return 
    false;
            }
        }

        
    /**
        * Smart!!! bind 
        * to fake habtm association
        * enables CRUD on habtm relationships
        *
        * @param string $assoc
        * @return className
        **/
        
    function smartHabtmBind($assoc,$unbind false) {
            if(isset(
    $this->hasAndBelongsToMany[$assoc])&&(isset($this->hasAndBelongsToMany[$assoc]['joinTable']))){
                
    $className Inflector::classify($this->hasAndBelongsToMany[$assoc]['joinTable']);
                if(!isset(
    $this->hasMany[$className])){
                    
    /*$this hasMany habtm & habtm belongsTo $this*/
                    
    if($unbind){
                        
    $this->unbindModel(array('hasMany' => array($className => array('className' => $className))));
                    }else{
                        
    $this->bindModel(array('hasMany' => array($className => array('className' => $className))));
                        if(!isset(
    $this->$className->belongsTo[$this->name])){
                            
    $this->$className->bindModel(array('belongsTo' => array($this->name => array('className' => $this->name))));
                        }
                    }
                }
                if(!isset(
    $this->$assoc->hasMany[$className])){
                    
    /*$this->$assoc hasMany habtm & habtm belongsTo $this->$assoc*/
                    
    if($unbind){
                        
    $this->$assoc->unbindModel(array('hasMany' => array($className => array('className' => $className))));
                    }else{
                        
    $this->$assoc->bindModel(array('hasMany' => array($className => array('className' => $className))));
                        if(!isset(
    $this->$assoc->$className->belongsTo[$this->$assoc->name])){
                            
    $this->$assoc->$className->bindModel(array('belongsTo' => array($this->$assoc->name => array('className' => $this->$assoc->name))));
                        }
                    }
                }
                return 
    $className;
            }else{
                return 
    false;
            }
        }

        
    /**
        * Smart!!! unbind
        * destroy fake habtm association
        *
        * @param string $assoc
        * @return className
        **/
        
    function smartHabtmUnbind($assoc) {
            if(
    $this->smartHabtmBind($assoc,true)===false){
                return 
    false;
            }else{
                return 
    true;
            }
        }

        function 
    __buildRecordSet($className,$assoc,$id,$assoc_id,$extra=null){

            
    $assoc_data = array();
            
    $fields Set::extract($this->$className->_tableInfo,'value.{n}.name');

            
    //building record set
            
    foreach($fields as $field){

                switch(
    $field){
                    case 
    $this->hasAndBelongsToMany[$assoc]['foreignKey']:
                        
    $assoc_data[$field] = $id;
                    break;
                    case 
    $this->hasAndBelongsToMany[$assoc]['associationForeignKey']:
                        
    $assoc_data[$field] = $assoc_id;
                    break;
                }

                if(
    is_array($extra)&&array_key_exists($field,$extra)){
                    
    $assoc_data[$field] = $extra[$field];
                }

            }

            return 
    $assoc_data;
        }
    }
    ?>

What is left to do

"The hard part is done ! The hardest remains"

Currently we are correcting the bugs reported by our users and there are still many of them. We're also working on some enhancements. Besides, we have a lot of work to do to ensure cross-browser compatibility (we want the website to work on browsers like IE 6 and that's not an easy thing...)

Thanks !

Thanks for reading this article. I Hope that you'll visit us soon at http://www.unaneem.com/ and give your feedbacks. If you want to follow our updates on unaneem's new developments you can also visit our developers blog (sorry in french only for now) at http://blog.unaneem.com/. And by the way thanks to the CakePHP developers for their great framework and to all the community for their contributions.

Comments

  • Posted 08/23/10 02:20:53 AM
    Well we are certainly impressed. For me, I have taken notes from your article and will explore and do some further research for my own website. I too have a yearning to create. The art form in developing a great site is what is driving me. I am not even certain yet of what the web site will be devoted to. It will come to me. I will say I have the opportunity to work with PHP apps and it is as every bit as good as it is described.
  • Posted 03/08/10 08:11:02 AM

    If you want more informations about the OpenInviter Custom component I'm using check this bakery article:

    http://bakery.cakephp.org/articles/view/openinviter-for-cakephp-2
  • Posted 02/24/10 01:44:23 AM
    i am also looking for this. any update please let us know.
  • Posted 01/21/10 07:19:36 AM
    Can you post your code here to of open inviter integration with cakePHP. Also, I would like to read your views about using opnen inviter with cakePHP. Would you recommend it to be used with it ?
    • Posted 02/24/10 06:21:50 AM
      Can you post your code here to of open inviter integration with cakePHP. Also, I would like to read your views about using opnen inviter with cakePHP. Would you recommend it to be used with it ?

      I disclosed a working version of the tool at :

      http://code.google.com/p/cakeoinviter/

      I have to update it with the latest openinviter updates. However I didn't have the time to do this already.

      The problem with openinviter is that the tool as it's provided have remote/hosted features that might be a threat to your server security or users privacy. This is why i had to re-engineer the tool.

      Now the problem with the solution I've come with is that as the openinviter tool evolves the cakephp openinviter component must evolve.

      But the frequency of the openinviter updates are on almost a daily basis. So updating / testing the component is hard task on your own without the help of the cake community.

      I've created an article about the cakephp openinviter component. I'll post the link here as soon as it will be available

  • Posted 12/19/09 09:08:19 AM
    Could I get some help from you on the OpenInviter integration with CakePHP?
    • Posted 12/24/09 08:46:43 AM
      Could I get some help from you on the OpenInviter integration with CakePHP?
      Hello,

      I re-enginereed the two openinviter core library files to remove all to references to hosted solutions and remote check and updates (i believe that these options are quite strange according to the privacy issues at stake with this kind of things).

      I built a CakePHP component based on the main openinviter class (openinviter.php) adapting the structure to something more cake-friendly and re-engineered the abstract opininviter plugin class (_base.php) to remove or plug with dummies the methods with remote accesses.

      Currently, at the update rate of the core openinviter objects I can't release a stable version of the openinviter cakephp component. I have to re-factor it each 2-3 weeks. I believe that when there will be a stable release i will disclose a cakephp friendly release on the bakery.

      However If you have specific questions about it, I will answer.
  • Posted 07/27/09 12:12:38 AM
    Your post reminds me of the days I build my application http://www.kiwitask.com. It is enjoyable to share the learnings with others. Many thanks! Maybe I should write some down about what I have learned from my app.
  • Posted 07/13/09 05:21:29 AM

    Hai Sami,


    Its really great.I appreciate and really impressed by the efforts you've made to do this.Am a 6 months old PHP developer.worked mainly with core php and codeigniter.And now asked to work in CAKEPHP...Very accidentally i came across with your website...Its really awesome.....And i found very difficult to learn cakephp,since it lacks in its techinical documentation.....So kindly give me some tips regarding where to begin.....where to find the cakephp stuff.....

    Thanks in advance

    .....My email id is haijerome@gmail.com
  • Posted 06/26/09 03:36:15 PM
    I'll rate you with 5 points, because i know how hard u worked and what your application mean to you, but experience, skill comes with years and there is much to learn.
    Your app definitely needs much improvements in ajax, and you should find something with 'state managing' to make a history available in ajax, i mean browsing back and forward and bookmarking.
    And the face of the site also lacks of style, but you had an idea, and i admire that.. good luck
    • Posted 08/28/09 01:34:37 PM
      ...'state managing' to make a history available in ajax, i mean browsing back and forward and bookmarking... Well well well, this is done :). I found a way to manage back and forward button on the ajax paginated search results. You can try it online. Bookmarking the search results are not a priority for now but i'll look forward to implement it.
    • Posted 06/27/09 11:29:12 AM
      ...but experience, skill comes with years and there is much to learn... You're right, still a lot of things to learn and by the way to do.
      ...'state managing' to make a history available in ajax, i mean browsing back and forward and bookmarking... Indeed, I'am searching a way to solve this problem on the search results. I think that's where you noticed that...
      ...the face of the site also lacks of style... Well, I'm more a developper than a designer, however I've implemented a system on the website based on a component/helper combination using gd to draw dynamically the borders and gradients. All the website colors and styles can be change by modifying and array of variables. But the user interface is not done yet. I want my users to be able to customize their user interface...
      The application is still in beta version. So I need to have real critics on the website to improve it and... my skills too. Thanks for your feedback !

Comments are closed for articles over a year old