Cakephp Blog Archive
Ruby on Rails versus CakePHP: A Syntax Comparison Guide
My time is typically split between Interchange and Spree development, but in a recent project for JackThreads, I jumped back into CakePHP code. CakePHP is one of the more popular PHP MVC frameworks and is inspired by Rails. I decided to put together a quick syntax comparison guide between CakePHP and Rails since I occasionally have to look up how to do some Rails-y thing in CakePHP.
Basic |
Ruby on Rails | CakePHP |
|---|---|---|
| MVC Code Inclusion | Rails is typically installed as a gem and source code lives in the user's gem library. In theory, a modified version of the Rails source code can be "frozen" to your application, but I would guess this is pretty rare. | CakePHP is typically installed in the application directory in a "cake/" directory. The "app/" directory contains application specific code. From my experience, this organization has allowed me to easily debug CakePHP objects, but didn't do much more for me. |
| Application Directory Structure |
app/ controllers/ models/ views/ helpers/ lib/ config/ public javascripts/ images/ stylesheets/ vendors/ plugins/ extensions/ |
controllers/ models/ views/ layouts/ elements/ ... config/ webroot/ tmp/ plugins/ vendors/ |
| Notes: | In Rails, layouts live in app/views/layouts/. In CakePHP, layouts live in views/layouts/ and helpers lie in views/helpers/. | |
| Creating an Application |
rails new my_app # Rails 3 after gem installation rails my_app # Rails <3 |
Download the compressed source code and create an application with the recommended directory structure. |
Models |
Ruby on Rails | CakePHP |
| Validation |
class Zone < ActiveRecord::Base validates_presence_of :name validates_uniqueness_of :name end |
class User extends AppModel {
var $name = 'User';
var $validate = array(
'email' => array(
'email-create' => array(
'rule' => 'email',
'message' => 'Invalid e-mail.',
'required' => true,
'on' => 'create'
)
)
);
}
|
| Relationships |
class Order < ActiveRecord::Base belongs_to :user has_many :line_items end |
class Invite extends AppModel {
var $name = 'Invite';
var $belongsTo = 'User';
var $hasMany = 'Campaigns';
}
|
| Special Relationships |
class Address < ActiveRecord::Base
has_many :billing_checkouts,
:foreign_key => "bill_address_id",
:class_name => "Checkout"
end
|
class Foo extends AppModel {
var $name = 'Foo';
var $hasMany = array(
'SpecialEntity' => array(
'className' => 'SpecialEntity',
'foreignKey' => 'entity_id',
'conditions' =>
array('Special.entity_class' => 'Foo'),
'dependent' => true
),
);
}
|
Controllers |
Ruby on Rails | CakePHP |
| Basic Syntax |
class FoosController < ActionController::Base helper :taxons actions :show, :index include Spree::Search layout 'special' end |
class FooController extends AppController {
var $name = 'Foo';
var $helpers = array('Server', 'Cart');
var $uses = array('SpecialEntity','User');
var $components = array('Thing1', 'Thing2');
var $layout = 'standard';
}
|
| Notes: | CakePHP and Rails use similar helper and layout declarations. In CakePHP, the $uses array initiates required models to be used in the controller, while in Rails all application models are available without an explicit include. In CakePHP, the $components array initiates required classes to be used in the controller, while in Rails you will use "include ClassName" to include a module. | |
| Filters |
class FoosController < ActionController::Base before_filter :load_data, :only => :show end |
class FooController extends AppController {
var $name = 'Foo';
function beforeFilter() {
parent::beforeFilter();
//do stuff
}
}
|
| Setting View Variables |
class FoosController < ActionController::Base
def index
@special_title = 'This is the Special Title!'
end
end
|
class FooController extends AppController {
var $name = 'Foo';
function index() {
$this->set('title',
'This is the Special Title!');
}
}
|
Views |
Ruby on Rails | CakePHP |
| Variable Display |
<%= @special_title %> |
<?= $special_title ?> |
| Looping |
# Rails 3 <%= @foos.each do |foo| -%> <%= foo.name %> <% end -%> # Rails <3 <% @foos.each do |foo| -%> <%= foo.name %> <% end -%> |
<?php foreach($items as $item): ?> <?= $item['name']; ?> <?php endforeach; ?> |
| Partial Views or Elements |
<%= render :partial => 'shared/view_name',
:locals => { :b => "abc" } %>
|
<?php echo $this->element('account_menu',
array('page_type' => 'contact')); ?>
|
| Notes: | In Rails, partial views typically can live anywhere in the app/views directory. A shared view will typically be seen in the app/views/shared/ directory and a model specific partial view will be seen in the app/views/model_name/ directory. In CakePHP, partial views are referred to as elements and live in the views/elements directory. | |
| CSS and JS |
<%= javascript_include_tag 'my_javascript', 'my_javascript2' %> <%= stylesheet_link_tag 'my_style' %> |
<?php
$html->css(array('my_style.css'),
null, array(), false);
$javascript->link(array('my_javascript.js'),
false);
?>
|
Routing |
Ruby on Rails | CakePHP |
| Basic |
# Rails 3 match '/cart', :to => 'orders#edit', :via => :get, :as => :cart # Rails <3 map.login '/login', :controller => 'user_sessions', :action => 'new' |
Router::connect('/refer',
array('controller' => 'invites',
'action' => 'refer'));
Router::connect(
'/sales/:sale_id',
array('controller' => 'sale',
'action' => 'show'),
array('sale_id' => '[0-9]+')
);
|
| Nested or Namespace Routing |
# Rails 3
namespace :admin do
resources :foos do
collection do
get :revenue
get :profit
end
end
end
# Rails <3
map.namespace :admin do |admin|
admin.resources :foos, :collection => {
:revenue => :get,
:profit => :get,
}
end
|
- |
Logging |
Ruby on Rails | CakePHP |
| Where to? | tmp/log/production.log or tmp/log/debug.log | tmp/logs/debug.log or tmp/logs/error.log |
| Logging Syntax |
Rails.logger.warn "steph!" # Rails 3 logger.warn "steph!" # Rails <3or RAILS_DEFAULT_LOGGER.warn "steph!" |
$this->log('steph!', LOG_DEBUG); |
If you are looking for guidance on choosing one of these technologies, below are common arguments. In End Point's case, we choose whatever technology makes the most sense for the client. We implemented a nifty solution for JackThreads to avoid a complete rewrite, described here in detail. We also work with existing open source ecommerce platforms such as Interchange and Spree and try to choose the best fit for each client.
Pick Me! |
Ruby on Rails | CakePHP |
|---|---|---|
|
|
CakePHP Infinite Redirects from Auto Login and Force Secure
Lately, Ron, Ethan, and I have been blogging about several of our CakePHP learning experiences, such as incrementally migrating to CakePHP, using the CakePHP Security component, and creating CakePHP fixtures for HABTM relationships. This week, I came across another blog-worthy topic while troubleshooting for JackThreads that involved auto login, requests that were forced to be secure, and infinite redirects.
Ack! Users were experiencing infinite redirects!
Some users were seeing infinite redirects. The following use cases identified the problem:
- Auto login true, click on link to secure or non-secure homepage => Whammy: Infinite redirect!
- Auto login false, click on link to secure or non-secure homepage => No Whammy!
- Auto login true, type in secure or non-secure homepage in new tab => No Whammy!
- Auto login false, type in secure or non-secure homepage in new tab => No Whammy!
So, the problem boiled down to an infinite redirect when auto login customers clicked to the site through a referer, such as a promotional email or a link to the site.
Identifying the Cause of the ProblemAfter I applied initial surface-level debugging without success, I decided to add excessive debugging to the code. I added debug statements throughout:
- the CakePHP Auth object
- the CakePHP Session object
- the app's app_controller beforeFilter that completed the auto login
- the app's component that forced a secure redirect on several pages (login, checkout, home)
I output the session id and request location with the following debug statement:
$this->log($this->Session->id().':'.$this->here.':'.'/*relevant message about whatsup*/', LOG_DEBUG);
With the debug statement shown above, I was able to compare the normal and infinite redirect output and identify a problem immediately:
normal output2009-12-09 11:44:55 Debug: d3c2297ddea9b76605cb7a459f45965b:/: User does not exist! 2009-12-09 11:44:55 Debug: d3c2297ddea9b76605cb7a459f45965b:/: Success in auto login! 2009-12-09 11:44:55 Debug: d3c2297ddea9b76605cb7a459f45965b:/: redirecting to /sale 2009-12-09 11:44:55 Debug: d3c2297ddea9b76605cb7a459f45965b:/sale: User exists! 2009-12-09 11:44:55 Debug: d3c2297ddea9b76605cb7a459f45965b:/sale: calling action!infinite redirect output
2009-12-09 11:43:30 Debug: 65cb23e4ca358b7270513cca4a52e9b7:/: User does not exist! 2009-12-09 11:43:30 Debug: 65cb23e4ca358b7270513cca4a52e9b7:/: Success in auto login! 2009-12-09 11:43:30 Debug: 65cb23e4ca358b7270513cca4a52e9b7:/: redirecting to /sale 2009-12-09 11:43:30 Debug: 397f099790347716e0bc58c73f23358d:/sale: User does not exist! 2009-12-09 11:43:30 Debug: 397f099790347716e0bc58c73f23358d:/sale: redirecting to /login 2009-12-09 11:43:30 Debug: 0dfee15a4295b26aad115ae37d470d30:/login: User does not exist! 2009-12-09 11:43:30 Debug: 0dfee15a4295b26aad115ae37d470d30:/login: Success in auto login! 2009-12-09 11:43:30 Debug: 0dfee15a4295b26aad115ae37d470d30 /login: redirecting to /sale 2009-12-09 11:43:31 Debug: 3f23b7f7bead5d23fd006b6d91b1d195:/sale: User does not exist! 2009-12-09 11:43:31 Debug: 3f23b7f7bead5d23fd006b6d91b1d195:/sale: redirecting to /login ...![]()
What I immediately noticed was that sessions were dropped at every redirect on the infinite redirect path. So I researched a bit and found the following resources:
- http://groups.google.com/group/cake-php/browse_thread/thread/4d7807465be56b03: A CakePHP google group message about lost sessions.
- http://book.cakephp.org/view/42/The-Configuration-Class: CakePHP documentation on the Security.level setting.
- http://www.php.net/manual/en/session.configuration.php#ini.session.referer-check: PHP documentation on referer_check.
As it turns out, the Security.level configuration affected the referer check for redirects. The CakePHP Session object set the referer_check to HTTP_HOST if Security.level was equal to 'high' or 'medium'. A couple of the resources mentioned above recommend to adjust the Security.level to 'low', which sounded like a potential solution. But I wasn't certain that this was the cause of the redirect, so I tested several changes to verify the problem.
First, I tested the Security.levels to 'high', 'medium', and 'low'. With the Security.level set to 'low', the infinite redirect would not happen and the debug log would show a consistent session id. Next, I commented out the code in the CakePHP Session object that set the referer_check and set the Security.level to 'high'. This also seemed to fix the infinite redirect, although, it wasn't ideal to make changes to the the core CakePHP code. Finally, I changed this->host to HTTPS_HOST instead of HTTP_HOST in the CakePHP Session object, so that the referer would be checked against the secure host rather than the non-secure host. This also fixed the infinite redirect, but again, it wasn't ideal to change the core CakePHP code.
I concluded that the secure redirect to the homepage or login page coupled with the auto login caused this infinite redirect. As pages were redirected between /login and /sale, the session (that stored the auto logged in user) was dropped since the referer check against HTTP_HOST failed.
The SolutionIn an ideal world, I would like to see HTTP_HOST and HTTPS_HOST included in the CakePHP referer check. But because we didn't want to edit the CakePHP core, I investigated the affect of changing the Security.level on the app:
|
Security.level == high |
Security.level == medium |
|
*Security.level == low |
Security.level is not set |
I provided this information to the client and let them decide which scenario met their business needs. For this situation, I recommended commenting out the Security.level configuration so that the session timeout would stay the same, but the cookie lifetime and inactiveMins values would increase.
This was an interesting learning experience that helped me understand a bit more about how CakePHP handles sessions. It also gave me exposure to referer checks in PHP, which I haven't dealt with much in the past.
Iterative Migration of Legacy Applications to CakePHP
As Steph noted, we recently embarked on an adventure with a client who had a legacy PHP app. The app was initially developed in rapid fashion, with changing business goals along the way. Some effort was made at the outset with this vanilla PHP app to put key business logic in classes, but as often happens over time the cleanliness of those classes degraded. While much of the business rules and state management (i.e. database manipulation, session wrangling, authentication/access-control, etc.) were kept separate from the "views" (the PHP entry pages), the classes themselves became tightly coupled, overburdened with myriad responsibilities, etc.
This was a far cry from the stereotypical spaghetti PHP app, but nevertheless it needed some reorganization; all but the smallest changes inevitably required touching a wide range of classes and pages, and the code would only grow more brittle unless some serious refactoring took place.
We determined at the outset that getting the application moved into an established MVC framework would be of great benefit, and further determined that CakePHP would be a good choice. (This is the point where anybody reading will inevitably ask in comments "Why CakePHP instead of My Preferred Awesome Framework?" Sigh.) The client agreed. The question became: how do we get there from here?
I spent some time investigating and inevitably came across the well-regarded three-part blog series:
(The author of that series has a book out on the subject, as well.)For somebody new to MVC application design, especially in the PHP space, the series (and presumably the related book) probably makes for pretty good reading. They present a decent approach to how the refactoring of legacy code can be accomplished. However, the series also appears to operate under the assumption that you're in a scrap-and-rebuild situation: the legacy app can essentially go nowhere for a few weeks while it gets gutted into CakePHP.
As noted in a review of the related book, the rebuild-it-all assumption doesn't apply to many real world situations. The more money your application makes, the more users it affects, the larger the feature set, the more likely it is that the business cannot afford to have an application sit in a code freeze while an entire rewrite takes place.
We ultimately opted for a different approach: iteratively migrate to CakePHP. The simplicity of the basic PHP paradigm makes this remarkably easy.
The basic steps:
- Rearrange the legacy application so it runs "within" CakePHP, with the CakePHP dispatcher handling the request but ultimately invoking the original legacy view
- Make adjustments to the legacy code such that it gets its database handle(s) from CakePHP rather than internally, it uses CakePHP's session, etc.
- New development can proceed within CakePHP; legacy logic can be refactored into CakePHP over time as the opportunity presents itself (or the situation demands)
Getting the application to run within CakePHP in this manner does not require that much effort. Of course, this would depend on your situation, but in the traditional model of presentation-oriented code relying on some business objects and a database, it works out. For the initial step:
- Prepare a basic CakePHP application
- Pull the legacy code into the CakePHP webroot, with the legacy pages moved under a new legacy/ subdirectory
- Prepare a "legacy" action in the default PageController that maps the requested URI path to a path relative to the legacy/ directory, then invokes the file living at that path
- Set up a new catch-all route that invokes this legacy action
function includeLegacyPage($path = null) {
// map the path passed in or from the request to the legacy/ subdirectory
$cakeRequestPath = $path ? $path : $this->controller->params['url']['url'];
$path = WWW_ROOT . 'legacy/' . $cakeRequestPath;
// This just maps input arguments to globals
$this->prepareGlobals(array('cakeRequestPath' => $cakeRequestPath));
// Resolve directories to an index.php page as necessary
if (is_dir($path)) {
if(substr($path, -1) != '/')
$path .= '/';
$path .= 'index.php';
}
if (!file_exists($path)) {
$this->controller->render('error');
}
try {
// buffer PHP output
ob_start();
// this "invokes" the legacy page and gathers its content
include $path;
// pull in the buffered content
$this->controller->output = ob_get_contents();
// stop output buffering
ob_end_clean();
} catch (JackExceptionRedirect $e) {
// We adjusted the legacy app's redirect functions to throw a custom exception
// class that we catch here, so we can use CakePHP's native redirection
$this->controller->redirect($e->location, $e->getCode(), false);
} catch (Exception $e) {
// All other errors propagate up
throw $e;
}
$this->controller->autoRender = false;
$this->controller->autoLayout = false;
}
Our PageController's "legacy" action uses the above routine to pull in the legacy page.
The second step, of getting CakePHP to control the session, the database handle, etc., involves some minor hacks. They don't feel elegant. They go outside the MVC pattern. But they provide the crucial glue necessary to put CakePHP in charge.
- Make the controller's session available from a global; adjust legacy code to use it instead of direct use of the PHP session. This means that CakePHP controls the session configuration.
- Make the CakePHP database handle available from a global as well; adjust your legacy database initialization code so it simply uses the global handle from CakePHP. Now CakePHP controls your database configuration, and CakePHP and the legacy code will use the same handle in a given request.
- And so on and so forth.
App::import('ConnectionManager');
$standard_globals = array(
'cakeDbh' => ConnectionManager::getDataSource('default')->connection,
'cakeSession' => $this->Session
);
$this->prepareGlobals($standard_globals);
Up until now, CakePHP's introduction into the mix hasn't added value. Having reached this point, however, you're ready to start taking advantage of CakePHP. From here, we refactored our special "legacy" action logic into a new "LegacyPage" component so any controller/action could use the mechanism. Then we were able to:
- Refactor legacy user authentication logic to use CakePHP's Auth core component
- Refactor various legacy pages to be fronted by CakePHP controller actions, moving the high-level flow control (input validation, user validation, and associated redirects) out of the legacy page and into the controller. This simplifies the legacy page (making it more strictly limited to presentation) and puts flow control where it belongs.
- For a new feature involving new data structures, developed a new CakePHP component to implement the business operations, new controllers/actions for aspects of the new functionality, and adjusted some legacy code to get data from the new component rather than original direct database calls or legacy class calls
So, what are the advantages of this approach, versus a slash-and-burn rewrite-it-all approach?
- We get to a point in which we're tangibly benefitting from CakePHP with minimal investment of time/money; contrast that with the potential expense of rewriting the entire application before the business sees any benefit
- While we proceeded in this work, the client was actively developing their legacy system; there was no need for a code freeze, and reconciling their changes with our work was fairly trivial; one git rebase took care of it (though I admittedly missed a couple things during the rebase, which we caught and fixed with some spot-checking).
- No repeating of oneself: by making the entire legacy application available within the context of the target framework, we don't need to spend cycles rewriting existing functionality; the do-it-from-scratch approach would, by contrast, require reimplementation of everything
- We can refactor the legacy code in a prioritized, iterative fashion: refactor the most important stuff first, and the less important stuff later.
- We can partially refactor specific pieces of legacy code, such as removing business/data logic from pages such that legacy pages become more like views in the MVC triad; we're not forced to redo an entire legacy subsystem to improve the code organization
- The legacy work that is solid and doesn't need much refactoring stays put, and is usable from the rest of the CakePHP application
We may well get to the point (in late 2010, perhaps) when all legacy code has been refactored into CakePHP's MVC architecture. Or perhaps not: the business has to balance competing priorities, and it may ultimately be that some aspects of the legacy code just don't get refactored because they aren't especially broken and the business need simply doesn't come up. That's part of the beauty here: we don't have to make that decision right now; we can let the real-world priorities make that decision for us over time.
It's easy to imagine an engineer finding this less attractive than a redo-it-in-my-favorite-framework-du-jour approach. It reeks of compromise. Yet, from a business standpoint the advantages are hard to dispute. From a technical standpoint, they're hard to dispute as well: faster, shorter cycles of development bring a higher likelihood of success, particularly for small teams (or lone individuals); the management of change is much simpler with iterative design; the iterative approach is arguably less prone to second-system effect than is a rewrite; etc.
This asks more of the engineer than does a ground-up rewrite in Framework X. So many modern frameworks positively shine with possibility; the engineer lusts for the opportunity to Do It Right, and falls prey to the fallacy that the framework will solve all their problems given that Done Right investment. But, whatever the features and community offerings may be, modern frameworks ultimately help us organize our code better; better organization of code is amongst the most obvious benefits one gets in moving into a modern framework.
The iterative approach gets us there with far less risk and, in many cases, far more naturally than does the rewrite-it-all approach, but it asks us to have the patience to move in small steps. It asks that we have the mental room and rigor to envision what the Done Right system might look like, as well as a long chain of interim steps taking us from here to there. But it delivers value much faster, at lower risk, at lower cost, and crucially, reduces redundant work and gives us the opportunity to change direction as we go. Consequently, for many -- even most -- business situations the iterative transformation is the system Done Right.
Using The Security Component and validatePost in CakePHP Gotcha
Recently, Ron, Ethan, and I worked on a JackThreads project. We are in the process of moving JackThreads' legacy PHP application to the CakePHP framework in addition to introducing new functionality for this project.
Several of the pages require secure requests:
- the home page (where users log in or create accounts)
- the login page
- the "invite" page (where users create an account)
- the checkout page
We referred to this article that discusses using the security component in CakePHP. Although this article covered the basics, we extended the concepts of the article by creating a CakePHP component with the custom security functionality to force a secure request and includes query string parameters. Below are the contents of the component that was created:
class StephsSecurityComponent extends Object {
var $components = array('Security');
function forceSecure($args) {
$this->Security->blackHoleCallback = 'forceSSL';
$this->Security->requireSecure($args);
}
function forceSSL($controller) {
$redirect_location = 'https://'.HTTPS_HOST.$controller->here;
$params = $controller->params['url'];
unset($params['url']);
if(count($params) > 0)
{
$param_string = '';
foreach($params as $key => $value)
$param_string .= '&'.$key.'='.$value;
$param_string = preg_replace('/^\&/', '?', $param_string);
$redirect_location .= $param_string;
}
$controller->redirect($redirect_location);
}
}
This design required the following definition in the application's app_controller:
function forceSSL() {
$this->StephsSecurity->forceSSL($this);
}
And any controller that required an action to be secure would call the forceSecure function in the beforeFilter:
function beforeFilter() {
$this->StephsSecurity->forceSecure('my_action');
}
For the most part, the security redirect worked as expected. The before filter in each controller correctly registered the action that required a secure request, and logging statements in the CakePHP core security component verified that the secure component would call the blackHoleCallback if the request was not secure. But then, we came across a bug!
One of the controllers that included this new functionality was not working as expected. The controller had two actions; both actions accepted inputs from forms and did stuff with those forms, only one of the actions required the force secure, one of the actions received form inputs from the CakePHP form helper and the other action received inputs from a legacy PHP page. The action that received inputs from a legacy PHP page didn't do stuff correctly. Below is a simplified version of this controller:
class ThisController extends AppController {
...
var $uses = array('Security', 'StephsSecurity');
function beforeFilter() {
$this->StephsSecurity->forceSecure('action_one');
}
function action_one() {
//receives inputs from a cakephp form helper
//do stuff with $this->params
}
function action_two() {
//receives inputs from a legacy php page
//do stuff with $this->params -- FAIL
}
}
We added debugging and found that $this->params (or the form parameters) to action_two was empty. We added logging to the beforeFilter to determine if the parameters were deleted during the beforeFilter process. We found that the parameters were present at the conclusion of the beforeFilter. So, at some point in between the beforeFilter and before the action, our form parameters were deleted.
function beforeFilter() {
$this->log($this->params, LOG_DEBUG);
//some other unrelated before filtering
$this->log($this->params, LOG_DEBUG);
$this->StephsSecurity->forceSecure('action_one');
$this->log($this->params, LOG_DEBUG); //parameters looked ok here!
}
After more troubleshooting, we determined that if the CakePHP core Security component wasn't included in the controller, the parameters were not deleted and the action did it's stuff. A review of the CakePHP core Security component revealed that the component performs a validation on posts, which includes a check for a Token input. Because the post to this action originated from a legacy PHP page, it did not include any special hidden form variables included with the use of the CakePHP form helper (much like the Token inputs included via the Rails form helper):
<input type="hidden" value="POST" name="_method"/> <input type="hidden" id="Token123123123 value="123123123131231231223" name="data[_Token][key]"/>
As a result, the black hole security redirect was called before action_two was reached, then action_two was called with missing parameters. Ethan realized there was a simple fix to this post validation failure. The Security->validatePost variable was set to false inside the controller's beforeFilter to bypass the _validatePost check in the security component. No more post validation produced expected action_two behavior.
function beforeFilter() {
$this->Security->validatePost = false;
$this->StephsSecurity->forceSecure('index');
}
Unfortunately, there isn't a lot of documentation on the CakePHP Security component that would have helped us identify this issue quickly. Configuration of the CakePHP Security component, discussed here, fails to mention the validatePost value, but it is included in the CakePHP API documentation.
Fortunately, it wasn't too difficult to troubleshoot once we observed the undesired behavior originated from the inclusion of the Security component in the controller. We are now aware of this Security post validation as we continue to transition legacy PHP to CakePHP. I'm sure we'll come across situations where data is passed from legacy pages or 3rd party services that do not contain the required Token variables and will require bypassing the _validatePost check.
Setting up a login form in a controller other then the Users controller in CakePHP, don't forget the User model
I ran into an issue today while setting up a login form on the front page of a site that would post to the login action of the User controller. The issue was that when the the form was posted the App controller beforeFilter was called, the User controller beforeFilter was called, but the login action of the User controller was never reached and a blank template with the normal debugging output was shown. No errors were being output and there wasn't much to go on. Ultimately what ended up being the problem was that in the Home controller where the form was being served from we did not have the following to include the User model:var $uses = array('User');
Surprisingly within our view we were able to setup forms to work with the User model. When the auth component was checking for the user data in the post it did not find any data, and stopped processing the request. This was not a graceful way for the auth component or CakePHP to handle the request, an error message would have helped track down the issue.
Test Fixtures for CakePHP Has-and-Belongs-to-Many Relationships
CakePHP, a popular MVC framework in/for PHP, offers a pretty easy-to-use object-relational mapper, as well as fairly straightforward fixture class for test data. Consequently, it's fairly easy to get into test-driven development with CakePHP, though this can take some acclimation if you're coming from Rails or Django or some such; the need to go through a web interface to navigate to and execute your test cases feels, to me, a little unnatural. Nevertheless, you can get writing tests pretty quickly, and the openness of the testing framework means that it won't get in your way. Indeed, compared to the overwhelming plethora of testing options one gets in the Ruby space -- and the accompanying sense that the choice of testing framework is akin to one's choice of religion, political party, or top 10 desert island album list -- CakePHP's straightforward testing feels a little liberating.
Which is why it was a little surprising to me that getting a test fixture going for the join table on a has-and-belongs-to-many (HABTM) association is -- at least in my experience -- not the clearest thing in the world.
One can presumably configure the fixture to merely use the table option in the fixture's $import attribute. However, as I was following the table and model naming conventions, I felt that I must be doing something wrong in my attempts to get a fixture going for a HABTM relationship, and consequently I eschewed the (potentially) easy way out to try to find a solution that ought to work.
So, let's say my relations were:
- Product model: some stuff to sell
- Sale model: individual "sale" events when particular products are promoted
- A products_sales join table establishes a many-to-many relationship (can we all acknowledge that "many-to-many" is much more convenient for meatspace communication than the horrendously awkward "has-and-belongs-to-many"?) between these two fabulous structures
You can go with the usual Cake-ish model definitions:
# in app/models/product.php
class Product extends AppModel {
$name = 'Product';
$hasAndBelongsToMany = array(
'Sale' => array('className' => 'Sale')
);
}
# in app/models/sale.php
class Sale extends AppModel {
$name = 'Sale';
$hasAndBelongsToMany = array(
'Product' => array('className' => 'Product')
);
}
Since we're following the naming conventions here (singular model name fronts pluralized table name, the join table for the HABTM relationship uses pluralized names for each relation joined, in alphabetical order), then the above code should be all you need for the relationship to work.
Indeed, as explained in this helpful article on the HABTM-in-CakePHP subject, you should find that queries using these models will automatically include 'ProductsSale' model entries in their result sets, with that model being dynamically generated by the HABTM association.
So, that means you should be able to create a test fixture for the ProductsSale model, right?
# in app/tests/fixtures/products_sale.php
class ProductsSale extends CakeTestFixture {
$name = 'ProductsSale';
$import = 'ProductsSale';
$records = array(
a buncha awesome stuff...
);
}
Unfortunately, at least with my experience on CakePHP 1.2.5, that doesn't work. When your test case attempts to load the fixture, you'll get SQL errors indicating that the test-prefixed version of your "products_sales" table doesn't exist.
I haven't done a sufficiently exhaustive analysis of the Cake innards to sort out why this is, and may yet do so. My guess based on nothing other than observation and intuition is that the auto-generated model is related only to the models involved in the HABTM relationship, through the bindModel method, and does not get generated in any global capacity such that it exists as a model in its own right. Consequently, while the testing code can guess the correct table name for the join table based on the naming conventions used for the fixture, since it doesn't relate to an extant model, it fails to go through the model-wrapping procedures that typically take place per test-case (setting up the test-space table per model, populating it from the fixture, etc.)
Fortunately, as illustrated by the aforementioned helpful article, we can front the join table with a full-fledged model class, and use that model class within the association definitions. This solves the problem of the broken fixture, as the fixture will now refer to a standard model and successfully set up the test table, data, etc.
That means the code becomes:
# in app/models/products_sale.php
class ProductsSale extends AppModel {
/* the naming convention assumes singularized model name
based on the entire table name; it does not make inner
names singular. This feels a little unclean. If it
really bothers you, recall the language you're using
and I suspect you'll get over it. */
$name = 'ProductsSale';
/* The join table belongs to both relations */
$belongsTo = array('Product', 'Sale');
}
# in app/models/product.php
class Product extends AppModel {
$name = 'Product';
/* Use the 'with' option to join through the new model class */
$hasAndBelongsToMany = array(
'Sale' => array('with' => 'ProductsSale')
);
}
# in app/models/sale.php
class Sale extends AppModel {
$name = 'Sale';
/* And again, the 'with' option */
$hasAndBelongsToMany = array(
'Product' => array('with' => 'ProductsSale')
);
}
No changes are necessary to the fixture for ProductsSale; once that join model is in place, it'll be good.
It is not uncommon for ORMs to provide magical intelligence for establishing HABTM relationships, and as a matter of convenience it's pretty handy. It is similarly common to allow for HABTM association through an explicitly-defined model class. While this ups the ceremony for setting up your ORM, there are benefits that come with it; a reduced reliance on magic can be distinctly advantageous if you ever get into hairy situations with ORM query wrangling, and it is reasonably common for a HABTM association to have annotations on the relationship itself. In each case, you'll be happy to have your join table fronted by a model class.
Hopefully this will save somebody else some trouble.


