diff --git a/includes/common.inc b/includes/common.inc
index 8755c94..82d8356 100644
--- a/includes/common.inc
+++ b/includes/common.inc
@@ -7773,6 +7773,47 @@ function entity_label($entity_type, $entity) {
 }
 
 /**
+ * Returns the language of an entity.
+ *
+ * This is an opt-in API function, thus modules implementing the language
+ * callback should be aware that it might not be always called.
+ *
+ * @param $entity_type
+ *   The entity type; e.g., 'node' or 'user'.
+ * @param $entity
+ *   The entity for which to get the language.
+ *
+ * @return
+ *   A valid language code or NULL if the entity has no language support.
+ */
+function entity_language($entity_type, $entity) {
+  $info = entity_get_info($entity_type);
+
+  // Invoke the callback to get the language. If there is no callback, try to
+  // get it from a property of the entity, otherwise NULL.
+  if (isset($info['language callback']) && function_exists($info['language callback'])) {
+    $langcode = $info['language callback']($entity_type, $entity);
+  }
+  elseif (!empty($info['entity keys']['language']) && isset($entity->{$info['entity keys']['language']})) {
+    $langcode = $entity->{$info['entity keys']['language']};
+  }
+  else {
+    // @todo The right value for D8 should be LANGUAGE_NONE, we cannot use it
+    // here to preserve backward compatibility. In fact this function has been
+    // introduced very late in the D7 life cycle as the proper default value for
+    // field_attach_form(). By returning LANGUAGE_NONE when no language
+    // information is available, we would introduce a potentially BC-breaking
+    // API change, since field_attach_form() defaults to the default language
+    // instead of LANGUAGE_NONE. Moreover this allows us to distinguish between
+    // entities that have no language specified from ones that do not have
+    // language support at all.
+    $langcode = NULL;
+  }
+
+  return $langcode;
+}
+
+/**
  * Helper function for attaching field API validation to entity forms.
  */
 function entity_form_field_validate($entity_type, $form, &$form_state) {
diff --git a/modules/comment/comment.module b/modules/comment/comment.module
index 6938833..429c3b0 100644
--- a/modules/comment/comment.module
+++ b/modules/comment/comment.module
@@ -103,6 +103,7 @@ function comment_entity_info() {
         'id' => 'cid',
         'bundle' => 'node_type',
         'label' => 'subject',
+        'language' => 'language',
       ),
       'bundles' => array(),
       'view modes' => array(
diff --git a/modules/locale/locale.module b/modules/locale/locale.module
index e0981b2..c3133bc 100644
--- a/modules/locale/locale.module
+++ b/modules/locale/locale.module
@@ -396,7 +396,7 @@ function locale_form_node_form_alter(&$form, &$form_state) {
 function locale_field_node_form_submit($form, &$form_state) {
   if (field_has_translation_handler('node', 'locale')) {
     $node = (object) $form_state['values'];
-    $available_languages = field_content_languages();
+    $current_language = entity_language('node', $node);
     list(, , $bundle) = entity_extract_ids('node', $node);
 
     foreach (field_info_instances('node', $bundle) as $instance) {
@@ -406,8 +406,8 @@ function locale_field_node_form_submit($form, &$form_state) {
 
       // Handle a possible language change: new language values are inserted,
       // previous ones are deleted.
-      if ($field['translatable'] && $previous_language != $node->language) {
-        $form_state['values'][$field_name][$node->language] = $node->{$field_name}[$previous_language];
+      if ($field['translatable'] && $previous_language != $current_language) {
+        $form_state['values'][$field_name][$current_language] = $node->{$field_name}[$previous_language];
         $form_state['values'][$field_name][$previous_language] = array();
       }
     }
diff --git a/modules/locale/locale.test b/modules/locale/locale.test
index ffda6f5..0c6da13 100644
--- a/modules/locale/locale.test
+++ b/modules/locale/locale.test
@@ -2788,8 +2788,9 @@ class LocaleCommentLanguageFunctionalTest extends DrupalWebTestCase {
           ->orderBy('cid', 'DESC')
           ->execute()
           ->fetchObject();
-        $args = array('%node_language' => $node_langcode, '%comment_language' => $comment->language, '%langcode' => $langcode);
-        $this->assertEqual($comment->language, $langcode, t('The comment posted with content language %langcode and belonging to the node with language %node_language has language %comment_language', $args));
+        $comment_langcode = entity_language('comment', $comment);
+        $args = array('%node_language' => $node_langcode, '%comment_language' => $comment_langcode, '%langcode' => $langcode);
+        $this->assertEqual($comment_langcode, $langcode, t('The comment posted with content language %langcode and belonging to the node with language %node_language has language %comment_language', $args));
       }
     }
   }
diff --git a/modules/node/node.admin.inc b/modules/node/node.admin.inc
index a1967c4..1508bc0 100644
--- a/modules/node/node.admin.inc
+++ b/modules/node/node.admin.inc
@@ -432,7 +432,8 @@ function node_admin_nodes() {
   $destination = drupal_get_destination();
   $options = array();
   foreach ($nodes as $node) {
-    $l_options = $node->language != LANGUAGE_NONE && isset($languages[$node->language]) ? array('language' => $languages[$node->language]) : array();
+    $langcode = entity_language('node', $node);
+    $l_options = $langcode != LANGUAGE_NONE && isset($languages[$langcode]) ? array('language' => $languages[$langcode]) : array();
     $options[$node->nid] = array(
       'title' => array(
         'data' => array(
@@ -449,11 +450,11 @@ function node_admin_nodes() {
       'changed' => format_date($node->changed, 'short'),
     );
     if ($multilanguage) {
-      if ($node->language == LANGUAGE_NONE || isset($languages[$node->language])) {
-        $options[$node->nid]['language'] = $node->language == LANGUAGE_NONE ? t('Language neutral') : t($languages[$node->language]->name);
+      if ($langcode == LANGUAGE_NONE || isset($languages[$langcode])) {
+        $options[$node->nid]['language'] = $langcode == LANGUAGE_NONE ? t('Language neutral') : t($languages[$langcode]->name);
       }
       else {
-        $options[$node->nid]['language'] = t('Undefined language (@langcode)', array('@langcode' => $node->language));
+        $options[$node->nid]['language'] = t('Undefined language (@langcode)', array('@langcode' => $langcode));
       }
     }
     // Build a list of all the accessible operations for the current node.
diff --git a/modules/node/node.install b/modules/node/node.install
index 8505234..0955d43 100644
--- a/modules/node/node.install
+++ b/modules/node/node.install
@@ -890,7 +890,7 @@ function node_update_7012() {
       ->execute();
 
     // Switch field languages to LANGUAGE_NONE, since initially they were
-    // assigned $node->language.
+    // assigned the node language.
     foreach (array('field_data_body', 'field_revision_body') as $table) {
       db_update($table)
         ->fields(array('language' => LANGUAGE_NONE))
diff --git a/modules/node/node.module b/modules/node/node.module
index 290df41..71ea3b9 100644
--- a/modules/node/node.module
+++ b/modules/node/node.module
@@ -177,6 +177,7 @@ function node_entity_info() {
         'revision' => 'vid',
         'bundle' => 'type',
         'label' => 'title',
+        'language' => 'language',
       ),
       'bundle keys' => array(
         'bundle' => 'type',
@@ -1682,7 +1683,7 @@ function node_search_execute($keys = NULL, $conditions = NULL) {
       'extra' => $extra,
       'score' => $item->calculated_score,
       'snippet' => search_excerpt($keys, $node->rendered),
-      'language' => $node->language,
+      'language' => entity_language('node', $node),
     );
   }
   return $results;
diff --git a/modules/node/node.pages.inc b/modules/node/node.pages.inc
index 89a1593..31d396a 100644
--- a/modules/node/node.pages.inc
+++ b/modules/node/node.pages.inc
@@ -299,7 +299,7 @@ function node_form($form, &$form_state, $node) {
   }
   $form += array('#submit' => array());
 
-  field_attach_form('node', $node, $form, $form_state, $node->language);
+  field_attach_form('node', $node, $form, $form_state, entity_language('node', $node));
   return $form;
 }
 
diff --git a/modules/node/node.test b/modules/node/node.test
index 6e458bb..37d05e5 100644
--- a/modules/node/node.test
+++ b/modules/node/node.test
@@ -2288,15 +2288,16 @@ class NodeTokenReplaceTestCase extends DrupalWebTestCase {
 
     // Generate and test sanitized tokens.
     $tests = array();
+    $langcode = entity_language('node', $node);
     $tests['[node:nid]'] = $node->nid;
     $tests['[node:vid]'] = $node->vid;
     $tests['[node:tnid]'] = $node->tnid;
     $tests['[node:type]'] = 'article';
     $tests['[node:type-name]'] = 'Article';
     $tests['[node:title]'] = check_plain($node->title);
-    $tests['[node:body]'] = _text_sanitize($instance, $node->language, $node->body[$node->language][0], 'value');
-    $tests['[node:summary]'] = _text_sanitize($instance, $node->language, $node->body[$node->language][0], 'summary');
-    $tests['[node:language]'] = check_plain($node->language);
+    $tests['[node:body]'] = _text_sanitize($instance, $langcode, $node->body[$langcode][0], 'value');
+    $tests['[node:summary]'] = _text_sanitize($instance, $langcode, $node->body[$langcode][0], 'summary');
+    $tests['[node:language]'] = check_plain($langcode);
     $tests['[node:url]'] = url('node/' . $node->nid, $url_options);
     $tests['[node:edit-url]'] = url('node/' . $node->nid . '/edit', $url_options);
     $tests['[node:author]'] = check_plain(format_username($account));
@@ -2315,9 +2316,9 @@ class NodeTokenReplaceTestCase extends DrupalWebTestCase {
 
     // Generate and test unsanitized tokens.
     $tests['[node:title]'] = $node->title;
-    $tests['[node:body]'] = $node->body[$node->language][0]['value'];
-    $tests['[node:summary]'] = $node->body[$node->language][0]['summary'];
-    $tests['[node:language]'] = $node->language;
+    $tests['[node:body]'] = $node->body[$langcode][0]['value'];
+    $tests['[node:summary]'] = $node->body[$langcode][0]['summary'];
+    $tests['[node:language]'] = $langcode;
     $tests['[node:author:name]'] = format_username($account);
 
     foreach ($tests as $input => $expected) {
diff --git a/modules/node/node.tokens.inc b/modules/node/node.tokens.inc
index 491ec81..e43db5e 100644
--- a/modules/node/node.tokens.inc
+++ b/modules/node/node.tokens.inc
@@ -144,7 +144,8 @@ function node_tokens($type, $tokens, array $data = array(), array $options = arr
           break;
 
         case 'language':
-          $replacements[$original] = $sanitize ? check_plain($node->language) : $node->language;
+          $langcode = entity_language('node', $node);
+          $replacements[$original] = $sanitize ? check_plain($langcode) : $langcode;
           break;
 
         case 'url':
diff --git a/modules/path/path.module b/modules/path/path.module
index 9df4988..1bb06f4 100644
--- a/modules/path/path.module
+++ b/modules/path/path.module
@@ -99,8 +99,9 @@ function path_form_node_form_alter(&$form, $form_state) {
   $path = array();
   if (!empty($form['#node']->nid)) {
     $conditions = array('source' => 'node/' . $form['#node']->nid);
-    if ($form['#node']->language != LANGUAGE_NONE) {
-      $conditions['language'] = $form['#node']->language;
+    $langcode = entity_language('node', $form['#node']);
+    if ($langcode != LANGUAGE_NONE) {
+      $conditions['language'] = $langcode;
     }
     $path = path_load($conditions);
     if ($path === FALSE) {
@@ -111,7 +112,7 @@ function path_form_node_form_alter(&$form, $form_state) {
     'pid' => NULL,
     'source' => isset($form['#node']->nid) ? 'node/' . $form['#node']->nid : NULL,
     'alias' => '',
-    'language' => isset($form['#node']->language) ? $form['#node']->language : LANGUAGE_NONE,
+    'language' => isset($langcode) ? $langcode : LANGUAGE_NONE,
   );
 
   $form['path'] = array(
@@ -192,8 +193,9 @@ function path_node_insert($node) {
     // Only save a non-empty alias.
     if (!empty($path['alias'])) {
       // Ensure fields for programmatic executions.
+      $langcode = entity_language('node', $node);
       $path['source'] = 'node/' . $node->nid;
-      $path['language'] = isset($node->language) ? $node->language : LANGUAGE_NONE;
+      $path['language'] = isset($langcode) ? $langcode : LANGUAGE_NONE;
       path_save($path);
     }
   }
@@ -210,13 +212,7 @@ function path_node_update($node) {
     if (!empty($path['pid']) && empty($path['alias'])) {
       path_delete($path['pid']);
     }
-    // Only save a non-empty alias.
-    if (!empty($path['alias'])) {
-      // Ensure fields for programmatic executions.
-      $path['source'] = 'node/' . $node->nid;
-      $path['language'] = isset($node->language) ? $node->language : LANGUAGE_NONE;
-      path_save($path);
-    }
+    path_node_insert($node);
   }
 }
 
@@ -234,7 +230,10 @@ function path_node_delete($node) {
 function path_form_taxonomy_form_term_alter(&$form, $form_state) {
   // Make sure this does not show up on the delete confirmation form.
   if (empty($form_state['confirm_delete'])) {
-    $path = (isset($form['#term']['tid']) ? path_load('taxonomy/term/' . $form['#term']['tid']) : array());
+    $langcode = entity_language('taxonomy_term', (object) $form['#term']);
+    $langcode = !empty($langcode) ? $langcode : LANGUAGE_NONE;
+    $conditions = array('source' => 'taxonomy/term/' . $form['#term']['tid'], 'language' => $langcode);
+    $path = (isset($form['#term']['tid']) ? path_load($conditions) : array());
     if ($path === FALSE) {
       $path = array();
     }
@@ -242,7 +241,7 @@ function path_form_taxonomy_form_term_alter(&$form, $form_state) {
       'pid' => NULL,
       'source' => isset($form['#term']['tid']) ? 'taxonomy/term/' . $form['#term']['tid'] : NULL,
       'alias' => '',
-      'language' => LANGUAGE_NONE,
+      'language' => $langcode,
     );
     $form['path'] = array(
       '#access' => user_access('create url aliases') || user_access('administer url aliases'),
@@ -274,7 +273,10 @@ function path_taxonomy_term_insert($term) {
     if (!empty($path['alias'])) {
       // Ensure fields for programmatic executions.
       $path['source'] = 'taxonomy/term/' . $term->tid;
-      $path['language'] = LANGUAGE_NONE;
+      // Core does not provide a way to store the term language but contrib
+      // modules can do it so we need to take this into account.
+      $langcode = entity_language('taxonomy_term', $term);
+      $path['language'] = !empty($langcode) ? $langcode : LANGUAGE_NONE;
       path_save($path);
     }
   }
@@ -295,7 +297,10 @@ function path_taxonomy_term_update($term) {
     if (!empty($path['alias'])) {
       // Ensure fields for programmatic executions.
       $path['source'] = 'taxonomy/term/' . $term->tid;
-      $path['language'] = LANGUAGE_NONE;
+      // Core does not provide a way to store the term language but contrib
+      // modules can do it so we need to take this into account.
+      $langcode = entity_language('taxonomy_term', $term);
+      $path['language'] = !empty($langcode) ? $langcode : LANGUAGE_NONE;
       path_save($path);
     }
   }
diff --git a/modules/system/system.api.php b/modules/system/system.api.php
index 3897a74..1e96a9c 100644
--- a/modules/system/system.api.php
+++ b/modules/system/system.api.php
@@ -97,6 +97,19 @@ function hook_hook_info_alter(&$hooks) {
  *     instead specify a callback function here, which will be called to
  *     determine the entity label. See also the entity_label() function, which
  *     implements this logic.
+ *   - language callback: (optional) A function taking an entity and an entity
+ *     type as arguments and returning the language the entity has been created
+ *     in. This value may be changed when editing the entity and represents the
+ *     language its textual components are supposed to have. In most situations,
+ *     when needing to determine this value, inspecting a property named after
+ *     the 'language' element of the 'entity keys' should be enough. However
+ *     some entity types might not define such a property, in which case the
+ *     language callback can be used to return the proper value. The language
+ *     callback is meant to be used primarily for temporary alterations of the
+ *     language property: entity-defining modules are encouraged to always rely
+ *     on it, instead of using the language callback as the main entity language
+ *     source. As a matter of fact not having a language property defined is
+ *     likely to prevent an entity to be queried by language.
  *   - fieldable: Set to TRUE if you want your entity type to accept fields
  *     being attached to it.
  *   - translation: An associative array of modules registered as field
@@ -123,6 +136,9 @@ function hook_hook_info_alter(&$hooks) {
  *       'subject' should be specified here. If complex logic is required to
  *       build the label, a 'label callback' should be defined instead (see
  *       the 'label callback' section above for details).
+ *     - language: The name of the property that contains the entity language
+ *       code, typically 'language'. If no language property is available, the
+ *       'language callback' may be used instead.
  *   - bundle keys: An array describing how the Field API can extract the
  *     information it needs from the bundle objects for this type. This entry
  *     is required if the 'path' provided in the 'bundles'/'admin' section
@@ -195,6 +211,7 @@ function hook_entity_info() {
         'id' => 'nid',
         'revision' => 'vid',
         'bundle' => 'type',
+        'language' => 'language',
       ),
       'bundle keys' => array(
         'bundle' => 'type',
diff --git a/modules/taxonomy/taxonomy.admin.inc b/modules/taxonomy/taxonomy.admin.inc
index a236cfe..828fde0 100644
--- a/modules/taxonomy/taxonomy.admin.inc
+++ b/modules/taxonomy/taxonomy.admin.inc
@@ -696,7 +696,8 @@ function taxonomy_form_term($form, &$form_state, $edit = array(), $vocabulary =
     '#value' => isset($term->vocabulary_machine_name) ? $term->vocabulary_machine_name : $vocabulary->name,
   );
 
-  field_attach_form('taxonomy_term', $term, $form, $form_state);
+  $langcode = entity_language('taxonomy_term', $term);
+  field_attach_form('taxonomy_term', $term, $form, $form_state, $langcode);
 
   $form['relations'] = array(
     '#type' => 'fieldset',
diff --git a/modules/translation/translation.module b/modules/translation/translation.module
index e61be1d..3312357 100644
--- a/modules/translation/translation.module
+++ b/modules/translation/translation.module
@@ -84,7 +84,7 @@ function translation_menu() {
  * @see translation_menu()
  */
 function _translation_tab_access($node) {
-  if ($node->language != LANGUAGE_NONE && translation_supported_type($node->type) && node_access('view', $node)) {
+  if (entity_language('node', $node) != LANGUAGE_NONE && translation_supported_type($node->type) && node_access('view', $node)) {
     return user_access('translate content');
   }
   return FALSE;
@@ -233,7 +233,7 @@ function translation_node_view($node, $view_mode) {
     foreach ($translations as $langcode => $translation) {
       // Do not show links to the same node, to unpublished translations or to
       // translations in disabled languages.
-      if ($translation->status && isset($languages[$langcode]) && $langcode != $node->language) {
+      if ($translation->status && isset($languages[$langcode]) && $langcode != entity_language('node', $node)) {
         $language = $languages[$langcode];
         $key = "translation_$langcode";
 
@@ -313,7 +313,7 @@ function translation_node_prepare($node) {
 
     // Add field translations and let other modules module add custom translated
     // fields.
-    field_attach_prepare_translation('node', $node, $node->language, $source_node, $source_node->language);
+    field_attach_prepare_translation('node', $node, $langcode, $source_node, $source_node->language);
   }
 }
 
@@ -358,7 +358,8 @@ function translation_node_insert($node) {
 function translation_node_update($node) {
   // Only act if we are dealing with a content type supporting translations.
   if (translation_supported_type($node->type)) {
-    if (isset($node->translation) && $node->translation && !empty($node->language) && $node->tnid) {
+    $langcode = entity_language('node', $node);
+    if (isset($node->translation) && $node->translation && !empty($langcode) && $node->tnid) {
       // Update translation information.
       db_update('node')
         ->fields(array(
@@ -389,7 +390,8 @@ function translation_node_validate($node, $form) {
   if (translation_supported_type($node->type) && (!empty($node->tnid) || !empty($form['#node']->translation_source->nid))) {
     $tnid = !empty($node->tnid) ? $node->tnid : $form['#node']->translation_source->nid;
     $translations = translation_node_get_translations($tnid);
-    if (isset($translations[$node->language]) && $translations[$node->language]->nid != $node->nid ) {
+    $langcode = entity_language('node', $node);
+    if (isset($translations[$langcode]) && $translations[$langcode]->nid != $node->nid ) {
       form_set_error('language', t('There is already a translation in this language.'));
     }
   }
@@ -469,7 +471,8 @@ function translation_node_get_translations($tnid) {
         ->execute();
 
       foreach ($result as $node) {
-        $translations[$tnid][$node->language] = $node;
+        $langcode = entity_language('node', $node);
+        $translations[$tnid][$langcode] = $node;
       }
     }
     return $translations[$tnid];
@@ -523,10 +526,11 @@ function translation_language_switch_links_alter(array &$links, $type, $path) {
       // have translations it might be a language neutral node, in which case we
       // must leave the language switch links unaltered. This is true also for
       // nodes not having translation support enabled.
-      if (empty($node) || $node->language == LANGUAGE_NONE || !translation_supported_type($node->type)) {
+      if (empty($node) || entity_language('node', $node) == LANGUAGE_NONE || !translation_supported_type($node->type)) {
         return;
       }
-      $translations = array($node->language => $node);
+      $langcode = entity_language('node', $node);
+      $translations = array($langcode => $node);
     }
     else {
       $translations = translation_node_get_translations($node->tnid);
diff --git a/modules/translation/translation.pages.inc b/modules/translation/translation.pages.inc
index fa4070b..110fea6 100644
--- a/modules/translation/translation.pages.inc
+++ b/modules/translation/translation.pages.inc
@@ -27,7 +27,7 @@ function translation_node_overview($node) {
   else {
     // We have no translation source nid, this could be a new set, emulate that.
     $tnid = $node->nid;
-    $translations = array($node->language => $node);
+    $translations = array(entity_language('node', $node) => $node);
   }
 
   $type = variable_get('translation_language_type', LANGUAGE_TYPE_INTERFACE);
diff --git a/modules/translation/translation.test b/modules/translation/translation.test
index 09bc9e3..e64f9cb 100644
--- a/modules/translation/translation.test
+++ b/modules/translation/translation.test
@@ -429,7 +429,7 @@ class TranslationTestCase extends DrupalWebTestCase {
 
     $result = TRUE;
     $languages = language_list();
-    $page_language = $languages[$node->language];
+    $page_language = $languages[entity_language('node', $node)];
     $translation_language = $languages[$translation->language];
     $url = url("node/$translation->nid", array('language' => $translation_language));
 
diff --git a/modules/user/user.module b/modules/user/user.module
index 94ecaa2..0c28397 100644
--- a/modules/user/user.module
+++ b/modules/user/user.module
@@ -159,6 +159,10 @@ function user_entity_info() {
       'uri callback' => 'user_uri',
       'label callback' => 'format_username',
       'fieldable' => TRUE,
+      // $user->language is only the preferred user language for the user
+      // interface textual elements. As it is not necessarily related to the
+      // language assigned to fields, we do not define it as the entity language
+      // key.
       'entity keys' => array(
         'id' => 'uid',
       ),
@@ -3707,7 +3711,8 @@ function user_register_form($form, &$form_state) {
 
   // Attach field widgets, and hide the ones where the 'user_register_form'
   // setting is not on.
-  field_attach_form('user', $form['#user'], $form, $form_state);
+  $langcode = entity_language('user', $form['#user']);
+  field_attach_form('user', $form['#user'], $form, $form_state, $langcode);
   foreach (field_info_instances('user', 'user') as $field_name => $instance) {
     if (empty($instance['settings']['user_register_form'])) {
       $form[$field_name]['#access'] = FALSE;
diff --git a/modules/user/user.pages.inc b/modules/user/user.pages.inc
index 25f4528..dc696d7 100644
--- a/modules/user/user.pages.inc
+++ b/modules/user/user.pages.inc
@@ -262,7 +262,8 @@ function user_profile_form($form, &$form_state, $account, $category = 'account')
   if ($category == 'account') {
     user_account_form($form, $form_state);
     // Attach field widgets.
-    field_attach_form('user', $account, $form, $form_state);
+    $langcode = entity_language('user', $account);
+    field_attach_form('user', $account, $form, $form_state, $langcode);
   }
 
   $form['actions'] = array('#type' => 'actions');
