unaneem.com : a community website built on CakePHP with extensive use of AJAX
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 :
- April 2007 to June2007 : Functional studies
- June 2007 to July 2007 : Templates design
- July 2007 to September 2007 : Database development
- September 2007 to October 2008 (now): Application development
- October 2008 : Production deployment : unaneem goes online !
- 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 :
- PHPThumb
- PHPBrowscap
- KCaptcha
- ...
JS Libraries :
- Scriptaculous
- SWFUpload
- Lightbox
- ...
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 :
- CakePHP (as our core PHP framework): http://cakephp.org/
- Prototype (as our main AJAX framework) : http://www.prototypejs.org/
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 :
- Apache 2: http://www.apache.org/
- MySQL 5.1: http://www.mysql.com/
- QMAIL: http://www.qmail.org/
Database design, integration and maintenance :
- DBDesigner 4: http://fabforce.net/dbdesigner4/
- PHPMyAdmin: http://www.phpmyadmin.net/
Programming tools:
- Notepad++: http://notepad-plus.sourceforge.net/
Web browsers (for cross-compatibility purpose) and browser debugging tools:
- Mozilla 2,3 (PC): http://www.mozilla.org/
- FireBug: http://getfirebug.com/
- IE 6,7,8 (PC): http://www.microsoft.com/
- Internet Explorer Developer ToolBar: http://www.microsoft.com/downloadS/details.aspx?familyid=E59C3964-672D-4511-BB3E-2D5E1DB91038&displaylang=en
- Chrome Browser (PC): http://www.google.com/chrome/
- Safari Browser (PC + MAC): http://www.apple.com/safari/
- Opera Browser (PC): http://www.opera.com/
- Konqueror Browser (LINUX): http://www.konqueror.org/
Graphic design and optimization :
- GIMP: http://www.gimp.org/
- PngOptimizer: http://psydk.org/PngOptimizer.php
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:
-
CSSpp for CSS compression:
- Library: https://trac.cakephp.org/browser/vendor/csspp/csspp.php
- This feature is one of CakePHP basic features.
-
KCaptcha for captcha generation:
- Library: http://www.captcha.ru/en/kcaptcha/
- Integration based on the following Bakery component: http://bakery.cakephp.org/articles/view/integrate-cakephp-with-kcaptcha
-
PHPThumb for thumbnails generation:
- Library: http://phpthumb.sourceforge.net/
- Integration based on the following Bakery component and snippet: http://bakery.cakephp.org/articles/view/phpthumb-component and http://bakery.cakephp.org/articles/view/thumbnails-generation-with-phpthumb
-
PHPBrowscap for accurate browser detection:
- Library: http://code.google.com/p/phpbrowscap/
- To integrate this library we developped a custom CakePHP component.
-
OpenInviter to provide a contact importer service to our users:
- Library: http://openinviter.com/
- To integrate this library we developped a custom CakePHP component.
-
HostIP for IP geotargeting (not very reliable, we're currently searching for another solution):
- Library: http://www.hostip.info/
- To integrate this library we developped a custom CakePHP component disclosed here : http://bakery.cakephp.org/articles/view/openinviter-for-cakephp-2.
The Javascript libraries we use with CakePHP:
-
FCKeditor for text editing:
- Library: http://www.fckeditor.net/
- Integration based on the following Bakery tutorial: http://bakery.cakephp.org/articles/view/using-fckeditor-with-cakephp
-
SWF Upload for AJAX picture upload:
- Library: http://swfupload.org/
- Integration based on the following Bakery component: http://bakery.cakephp.org/articles/view/swfupload-and-multipurpose-uploader
-
Scriptaculous for various graphic effects:
- Library: http://script.aculo.us/
- To integrate this library we developped a custom helper.
-
LightBox 2 for full image display:
- Library: http://www.lokeshdhakar.com/projects/lightbox2/
- To integrate this library we developped a custom helper.
-
HelpBalloon 2 to display tooltips:
- Library: http://www.beauscott.com/examples/help_balloons/doc/examples.php
- To integrate this library we developped a custom helper.
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
-
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.
-
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.
-
Database development
We created the database according to our functional chart with dbdesigner and tested it with PMA (PhpMyAdmin).
-
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.
-
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.
-
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
-
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;
}
}
-
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:
- http://en-us.unaneem.com/home/index
- http://es-es.unaneem.com/home/index
- http://fr-fr.unaneem.com/home/index
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')); -
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.








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-2I 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
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.
Hai Sami,
Its really great.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.comYour 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
Indeed, I'am searching a way to solve this problem on the search results. I think that's where you noticed that...
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