diff --git a/CRM/Utils/Array.php b/CRM/Utils/Array.php
index c6e8cfb93a86ff2c9ef9d9ace3ba3378f1007b47..575c0e66b880594f254cbab8145633d0c5ebe319 100644
--- a/CRM/Utils/Array.php
+++ b/CRM/Utils/Array.php
@@ -635,5 +635,48 @@ class CRM_Utils_Array {
     }
     return $default;
   }
+
+  /**
+   * Generate the Cartesian product of zero or more vectors
+   *
+   * @param array $dimensions list of dimensions to multiply; each key is a dimension name; each value is a vector
+   * @param array $template a base set of values included in every output
+   * @return array each item is a distinct combination of values from $dimensions
+   *
+   * For example, the product of
+   * {
+   *   fg => {red, blue},
+   *   bg => {white, black}
+   * }
+   * would be
+   * {
+   *   {fg => red, bg => white},
+   *   {fg => red, bg => black},
+   *   {fg => blue, bg => white},
+   *   {fg => blue, bg => black}
+   * }
+   */
+  static function product($dimensions, $template = array()) {
+    if (empty($dimensions)) {
+      return array($template);
+    }
+
+    foreach ($dimensions as $key => $value) {
+      $firstKey = $key;
+      $firstValues = $value;
+      break;
+    }
+    unset($dimensions[$key]);
+
+    $results = array();
+    foreach ($firstValues as $firstValue) {
+      foreach (self::product($dimensions, $template) as $result) {
+        $result[$firstKey] = $firstValue;
+        $results[] = $result;
+      }
+    }
+
+    return $results;
+  }
 }
 
diff --git a/js/crm.backbone.js b/js/crm.backbone.js
index 87c1c5cbc3f0aa0df8e8ce680c570aa72e0522ea..4913abfbe9d114a4f328e01796ad8f36895ff50d 100644
--- a/js/crm.backbone.js
+++ b/js/crm.backbone.js
@@ -32,14 +32,14 @@
       };
       switch (method) {
         case 'read':
-          CRM.api(model.crmEntityName, 'get', model.toCrmCriteria(), apiOptions);
+          CRM.api(model.crmEntityName, model.toCrmAction('get'), model.toCrmCriteria(), apiOptions);
           break;
         // replace all entities matching "x.crmCriteria" with new entities in "x.models"
         case 'crm-replace':
           var params = this.toCrmCriteria();
           params.version = 3;
           params.values = this.toJSON();
-          CRM.api(model.crmEntityName, 'replace', params, apiOptions);
+          CRM.api(model.crmEntityName, model.toCrmAction('replace'), params, apiOptions);
           break;
         default:
           apiOptions.error({is_error: 1, error_message: "CRM.Backbone.sync(" + method + ") not implemented for collections"});
@@ -74,9 +74,9 @@
           params.options || (params.options = {});
           params.options.reload = 1;
           if (!model._isDuplicate) {
-            CRM.api(model.crmEntityName, 'create', params, apiOptions);
+            CRM.api(model.crmEntityName, model.toCrmAction('create'), params, apiOptions);
           } else {
-            CRM.api(model.crmEntityName, 'duplicate', params, apiOptions);
+            CRM.api(model.crmEntityName, model.toCrmAction('duplicate'), params, apiOptions);
           }
           break;
         case 'read':
@@ -87,7 +87,7 @@
             apiOptions.error({is_error: 1, error_message: 'Missing ID for ' + model.crmEntityName});
             return;
           }
-          CRM.api(model.crmEntityName, apiAction, params, apiOptions);
+          CRM.api(model.crmEntityName, model.toCrmAction(apiAction), params, apiOptions);
           break;
         default:
           apiOptions.error({is_error: 1, error_message: "CRM.Backbone.sync(" + method + ") not implemented for models"});
@@ -116,6 +116,10 @@
     // Defaults - if specified in ModelClass, preserve
     _.defaults(ModelClass.prototype, {
       crmEntityName: crmEntityName,
+      crmActions: {}, // map: string backboneActionName => string serverSideActionName
+      toCrmAction: function(action) {
+        return this.crmActions[action] ? this.crmActions[action] : action;
+      },
       toCrmCriteria: function() {
         return (this.get('id')) ? {id: this.get('id')} : {};
       },
@@ -308,6 +312,10 @@
     // Defaults - if specified in CollectionClass, preserve
     _.defaults(CollectionClass.prototype, {
       crmEntityName: CollectionClass.prototype.model.prototype.crmEntityName,
+      crmActions: {}, // map: string backboneActionName => string serverSideActionName
+      toCrmAction: function(action) {
+        return this.crmActions[action] ? this.crmActions[action] : action;
+      },
       toCrmCriteria: function() {
         return (this.crmCriteria) ? _.extend({}, this.crmCriteria) : {};
       },
@@ -365,6 +373,9 @@
         } else if (options.crmCriteria) {
           this.crmCriteria = options.crmCriteria;
         }
+        if (options.crmActions) {
+          this.crmActions = _.extend(this.crmActions, options.crmActions);
+        }
         if (origInit) {
           return origInit.apply(this, arguments);
         }
diff --git a/tests/phpunit/CRM/Utils/ArrayTest.php b/tests/phpunit/CRM/Utils/ArrayTest.php
index 8b2509f2fa6d4cc4e2b79df74c70e49f1a722d38..2d31965abc658b9f0374969b2294ae4972d33f6a 100644
--- a/tests/phpunit/CRM/Utils/ArrayTest.php
+++ b/tests/phpunit/CRM/Utils/ArrayTest.php
@@ -33,7 +33,7 @@ class CRM_Utils_ArrayTest extends CiviUnitTestCase {
     $inputs[] = array(
       'lang' => 'en',
       'msgid' => 'greeting',
-      'familiar' => false,
+      'familiar' => FALSE,
       'value' => 'Hello'
     );
     $inputs[] = array(
@@ -54,7 +54,7 @@ class CRM_Utils_ArrayTest extends CiviUnitTestCase {
     $inputs[] = array(
       'lang' => 'en',
       'msgid' => 'greeting',
-      'familiar' => true,
+      'familiar' => TRUE,
       'value' => 'Hey'
     );
 
@@ -82,4 +82,41 @@ class CRM_Utils_ArrayTest extends CiviUnitTestCase {
     $this->assertEquals($expected, CRM_Utils_Array::collect('catWord', $arr));
   }
 
+  function testProduct0() {
+    $actual = CRM_Utils_Array::product(
+      array(),
+      array('base data' => 1)
+    );
+    $this->assertEquals(array(
+      array('base data' => 1),
+    ), $actual);
+  }
+
+  function testProduct1() {
+    $actual = CRM_Utils_Array::product(
+      array('dim1' => array('a', 'b')),
+      array('base data' => 1)
+    );
+    $this->assertEquals(array(
+      array('base data' => 1, 'dim1' => 'a'),
+      array('base data' => 1, 'dim1' => 'b'),
+    ), $actual);
+  }
+
+  function testProduct3() {
+    $actual = CRM_Utils_Array::product(
+      array('dim1' => array('a', 'b'), 'dim2' => array('alpha', 'beta'), 'dim3' => array('one', 'two')),
+      array('base data' => 1)
+    );
+    $this->assertEquals(array(
+      array('base data' => 1, 'dim1' => 'a', 'dim2' => 'alpha', 'dim3' => 'one'),
+      array('base data' => 1, 'dim1' => 'a', 'dim2' => 'alpha', 'dim3' => 'two'),
+      array('base data' => 1, 'dim1' => 'a', 'dim2' => 'beta', 'dim3' => 'one'),
+      array('base data' => 1, 'dim1' => 'a', 'dim2' => 'beta', 'dim3' => 'two'),
+      array('base data' => 1, 'dim1' => 'b', 'dim2' => 'alpha', 'dim3' => 'one'),
+      array('base data' => 1, 'dim1' => 'b', 'dim2' => 'alpha', 'dim3' => 'two'),
+      array('base data' => 1, 'dim1' => 'b', 'dim2' => 'beta', 'dim3' => 'one'),
+      array('base data' => 1, 'dim1' => 'b', 'dim2' => 'beta', 'dim3' => 'two'),
+    ), $actual);
+  }
 }