While working on restructuring some of the screen generation scripts for our site, I ran into an interesting problem with Yii Framework's CGridView widget and models that come from a MySQL table with a composite primary key. The error was actually a PHP warning which read "urlencode() expects parameter 1 to be string, array given". Displayed as shown below.

Without getting into other potential workarounds or arguing possible flaws in the structure of my data, I'm going to just blindly address the problem at hand - we (for whatever reason) have a model that needs to be loaded via composite key. Why doesn't it work? And how can we fix it?

Why? - Understanding the Problem

Before we dig in, please understand that without knowing PHP and the Yii Framework to some extent, this information will be seemingly useless. In addition to that, if you are just looking for the solution to this problem, feel free to skip down to the next section. I warn you, this may be rather dry... but I'll be as light as possible.

In this exact case (and others could exist, though they would follow a near identical path of logic), we are seeing this error when the CGridView widget attempts to create the default CButtonColumn buttons - view, update, and delete. It does that by passing the actions of 'view', 'update', or 'delete', respectively, to the current controller's createUrl method.

The createUrl method on CController takes the route and "params" for the route as parameters. In this case, the routes are just the action names and the params are just the primary key field and value. Yii uses the actual database schema to determine what field (or fields) to use as the primary key. So in a perfect world that works as expected with the default settings, you would see 3 calls to CController->createUrl; similar to what's shown below.

CController->createUrl('view',array('idField'=>'idValue'));
CController->createUrl('update',array('idField'=>'idValue'));
CController->createUrl('delete',array('idField'=>'idValue'));

However, the composite key throws this off already and causes us to get calls like the following.

CController->createUrl('view',array('idField'=>array('key1'=>'value1','key2'=>'value2')));
CController->createUrl('update',array('idField'=>array('key1'=>'value1','key2'=>'value2')));
CController->createUrl('delete',array('idField'=>array('key1'=>'value1','key2'=>'value2')));

Understanding that, let's continue to go deeper. The next noteworthy thing that occurs is a call to CApplication->createUrl. This takes the same parameters as the method on CController.

CApplication createUrl simply calls createUrl on the UrlManager; which in a default setup would be an instance of CUrlManager. The important part of that call is where it loops through all of the urlRules defined in your application's main config file and calls createUrl on the actual rule - which is an instance of CUrlRule (assuming a default setup). If CUrlRule->createUrl returns a value other than the boolean value of false (which means it found a rule that applies to the URL), it does some fancy stuff and returns the generated URL.

If no rules are applicable to the requested url, the url manager then calls createUrlDefault on itself. If that happens, the composite key won't cause any problems. Your code elsewhere may not be set up to handle it accordingly, but it will not result in an error. Instead you get a url that looks something like this: http://www.mysite.com/controller/action/id[key1]/value1/id[key2]/value2

The URL there is styled as if you had the URL manager's urlFormat set to 'path'. Also, in the actual result, the brackets [] would be URL encoded, but these details aren't really important. That sends the "id" parameter as a array in the request. So, what is the problem?

If a matching rule is found, it won't break the parameter values up like the default logic. The problem code is actually inside the CUrlRule's createUrl method. About 2/3 of the way into the method, it loops through the params and URL encodes each value and sets it to the correct key to be replaced in the rule's pattern, such as <id>. However, as the error clearly states, an array cannot be directly URL encoded; hence, a "composite-key-based" model will fail in these cases as its value is an array.

Before you blame Yii and wonder "why not just check if the value is an array and break it up there like they do in the default logic", you need to understand the situation better - you have a rule defined saying "plug this value into the url where you see <this>". Even in the best case, they cannot do that without potentially formatting the URL completely differently than what the rule specifies; which wouldn't be good, nor would it make much sense.

I hope you understand the problem after all of that. Now, what can we do to fix it?

Solutions

I trimmed the problem down to really being much simpler - if I pass an array into a URL, it should not match a pattern and instead use the createUrlDefault method.

To make this happen, we need to change the logic of createUrl on the rule's level. Now, we can't change the framework, but we can extend that class and tell our URL Manager to use our rule class. I created my new class in the components directory. Its contents are as follows.

class MyUrlRule extends CUrlRule
{
    public function createUrl($manager,$route,$params,$ampersand)
    {
        foreach($params as $key=>$value)
            if((array)$value === $value)
                return false;

        return parent::createUrl($manager,$route,$params,$ampersand);
    }
}

Then just set the 'urlRuleClass' setting on your URL manager settings in the main config file. Similar to what is shown below.

return array(
    ...
    'components' => array(
        ...
        'urlManager' => array(
            'urlRuleClass' => 'MyUrlRule',
            'rules' => array(
                ...
            ),
        ),
        ...
    ),
    ...
);

Now, I am obviously not able to promise this solution will work for everyone. Perhaps you need something more in-depth. I stumbled onto this while working on a project and found teh solution that would work for me. If you have a similar issue that needs a different solution, message me or post a comment. I'd gladly look a little more for something potentially even better.

Questions? Thoughts? Comments? Post them below and thank you for reading!