diff --git a/config/schema/search_api.index.schema.yml b/config/schema/search_api.index.schema.yml
index 866494e..5eb4a60 100644
--- a/config/schema/search_api.index.schema.yml
+++ b/config/schema/search_api.index.schema.yml
@@ -39,6 +39,8 @@ search_api.index.*:
           boost:
             type: float
             label: 'The boost of the field'
+          configuration:
+            type: search_api.property_configuration.[%parent.property_path]
           indexed_locked:
             type: boolean
             label: 'Whether the field is locked or can be removed'
diff --git a/config/schema/search_api.processor.schema.yml b/config/schema/search_api.processor.schema.yml
index 1448267..dff0e62 100644
--- a/config/schema/search_api.processor.schema.yml
+++ b/config/schema/search_api.processor.schema.yml
@@ -1,6 +1,8 @@
-plugin.plugin_configuration.search_api_processor.add_url:
+# Base definitions for processors
+
+search_api.default_processor_configuration:
   type: mapping
-  label: 'Add URL configuration'
+  label: 'Default processor configuration'
   mapping:
     weights:
       type: sequence
@@ -8,6 +10,11 @@ plugin.plugin_configuration.search_api_processor.add_url:
       sequence:
         type: integer
         label: 'The processor''s weight for this stage'
+
+search_api.fields_processor_configuration:
+  type: search_api.default_processor_configuration
+  label: 'Fields processor configuration'
+  mapping:
     fields:
       type: sequence
       label: 'The selected fields'
@@ -15,46 +22,17 @@ plugin.plugin_configuration.search_api_processor.add_url:
         type: string
         label: 'Selected field'
 
-plugin.plugin_configuration.search_api_processor.aggregated_field:
-  type: mapping
-  label: 'Add aggregation processor configuration'
-  mapping:
-    weights:
-      type: sequence
-      label: 'The processor''s weights for the different processing stages'
-      sequence:
-        type: integer
-        label: 'The processor''s weight for this stage'
-    fields:
-      type: sequence
-      label: 'The aggregated fields configured for this index'
-      sequence:
-        type: mapping
-        label: 'One field to add'
-        mapping:
-          fields:
-            type: sequence
-            label: 'The selected fields to be aggregated'
-            sequence:
-              type: string
-              label: 'One field that should be part of the aggregation'
-          label:
-            type: string
-            label: 'The label of the new field'
-          type:
-            type: string
-            label: 'The type of the aggregation'
+# Default for any processor without specific configuration
+
+plugin.plugin_configuration.search_api_processor.*:
+  type: search_api.default_processor_configuration
+
+# Definitions for individual processors
 
 plugin.plugin_configuration.search_api_processor.highlight:
-  type: mapping
+  type: search_api.default_processor_configuration
   label: 'Highlight processor configuration'
   mapping:
-    weights:
-      type: sequence
-      label: 'The processor''s weights for the different processing stages'
-      sequence:
-        type: integer
-        label: 'The processor''s weight for this stage'
     prefix:
       type: string
       label: 'Text/HTML that will be prepended to all occurrences of search keywords in highlighted text'
@@ -72,15 +50,9 @@ plugin.plugin_configuration.search_api_processor.highlight:
       label: 'Defines whether returned fields should be highlighted (always/if returned/never).'
 
 plugin.plugin_configuration.search_api_processor.html_filter:
-  type: mapping
+  type: search_api.default_processor_configuration
   label: 'HTML filter processor configuration'
   mapping:
-    weights:
-      type: sequence
-      label: 'The processor''s weights for the different processing stages'
-      sequence:
-        type: integer
-        label: 'The processor''s weight for this stage'
     fields:
       type: sequence
       label: 'The selected fields'
@@ -101,38 +73,13 @@ plugin.plugin_configuration.search_api_processor.html_filter:
         label: Boost
 
 plugin.plugin_configuration.search_api_processor.ignorecase:
-  type: mapping
+  type: search_api.fields_processor_configuration
   label: 'Ignore case processor configuration'
-  mapping:
-    weights:
-      type: sequence
-      label: 'The processor''s weights for the different processing stages'
-      sequence:
-        type: integer
-        label: 'The processor''s weight for this stage'
-    fields:
-      type: sequence
-      label: 'The selected fields'
-      sequence:
-        type: string
-        label: 'Selected field'
 
 plugin.plugin_configuration.search_api_processor.ignore_character:
-  type: mapping
+  type: search_api.fields_processor_configuration
   label: 'Ignore Character processor configuration'
   mapping:
-    weights:
-      type: sequence
-      label: 'The processor''s weights for the different processing stages'
-      sequence:
-        type: integer
-        label: 'The processor''s weight for this stage'
-    fields:
-      type: sequence
-      label: 'The selected fields'
-      sequence:
-        type: string
-        label: 'Selected field'
     ignorable:
       type: string
       label: 'Regular expression for characters it should ignore to stem'
@@ -147,76 +94,10 @@ plugin.plugin_configuration.search_api_processor.ignore_character:
             type: ignore
             label: 'Character set'
 
-plugin.plugin_configuration.search_api_processor.language:
-  type: mapping
-  label: 'Language field processor configuration'
-  mapping:
-    weights:
-      type: sequence
-      label: 'The processor''s weights for the different processing stages'
-      sequence:
-        type: integer
-        label: 'The processor''s weight for this stage'
-    fields:
-      type: sequence
-      label: 'The selected fields'
-      sequence:
-        type: string
-        label: 'Selected field'
-
-plugin.plugin_configuration.search_api_processor.node_status:
-  type: mapping
-  label: 'node status processor configuration'
-  mapping:
-    weights:
-      type: sequence
-      label: 'The processor''s weights for the different processing stages'
-      sequence:
-        type: integer
-        label: 'The processor''s weight for this stage'
-    fields:
-      type: sequence
-      label: 'The selected fields'
-      sequence:
-        type: string
-        label: 'Selected field'
-
-plugin.plugin_configuration.search_api_processor.rendered_item:
-  type: mapping
-  label: 'Rendered item processor configuration'
-  mapping:
-    weights:
-      type: sequence
-      label: 'The processor''s weights for the different processing stages'
-      sequence:
-        type: integer
-        label: 'The processor''s weight for this stage'
-    view_mode:
-      type: sequence
-      label: 'The selected view modes for each datasource, by bundle'
-      sequence:
-        type: sequence
-        label: 'The selected view modes for the datasource, by bundle'
-        sequence:
-          type: string
-          label: 'The view mode used to render the entity for the specified bundle'
-    roles:
-      type: sequence
-      label: 'The selected roles'
-      sequence:
-        type: string
-        label: 'The user roles which will be active when the entity is rendered'
-
 plugin.plugin_configuration.search_api_processor.role_filter:
-  type: mapping
+  type: search_api.default_processor_configuration
   label: 'Role filter processor configuration'
   mapping:
-    weights:
-      type: sequence
-      label: 'The processor''s weights for the different processing stages'
-      sequence:
-        type: integer
-        label: 'The processor''s weight for this stage'
     default:
       type: boolean
       label: 'Default'
@@ -228,21 +109,9 @@ plugin.plugin_configuration.search_api_processor.role_filter:
         label: 'The role name'
 
 plugin.plugin_configuration.search_api_processor.stopwords:
-  type: mapping
+  type: search_api.fields_processor_configuration
   label: 'Stopwords processor configuration'
   mapping:
-    weights:
-      type: sequence
-      label: 'The processor''s weights for the different processing stages'
-      sequence:
-        type: integer
-        label: 'The processor''s weight for this stage'
-    fields:
-      type: sequence
-      label: 'The selected fields'
-      sequence:
-        type: string
-        label: 'Selected field'
     stopwords:
       type: sequence
       label: 'entered stopwords'
@@ -251,21 +120,9 @@ plugin.plugin_configuration.search_api_processor.stopwords:
         label: Stopword
 
 plugin.plugin_configuration.search_api_processor.tokenizer:
-  type: mapping
+  type: search_api.fields_processor_configuration
   label: 'Tokenizer processor configuration'
   mapping:
-    weights:
-      type: sequence
-      label: 'The processor''s weights for the different processing stages'
-      sequence:
-        type: integer
-        label: 'The processor''s weight for this stage'
-    fields:
-      type: sequence
-      label: 'The selected fields'
-      sequence:
-        type: string
-        label: 'Selected field'
     spaces:
       type: string
       label: 'Regular expression for spaces'
@@ -280,35 +137,46 @@ plugin.plugin_configuration.search_api_processor.tokenizer:
       label: 'Defines the minimum word size'
 
 plugin.plugin_configuration.search_api_processor.transliteration:
-  type: mapping
+  type: search_api.fields_processor_configuration
   label: 'Transliteration processor configuration'
+
+# Definitions for property configuration
+
+search_api.property_configuration.*:
+  type: mapping
+  label: 'Default field configuration'
+  mapping: {}
+
+search_api.property_configuration.aggregated_field:
+  type: mapping
+  label: 'Aggregated field configuration'
   mapping:
-    weights:
-      type: sequence
-      label: 'The processor''s weights for the different processing stages'
-      sequence:
-        type: integer
-        label: 'The processor''s weight for this stage'
+    type:
+      type: string
+      label: 'The type of the aggregation'
     fields:
       type: sequence
-      label: 'The selected fields'
+      label: 'The properties to be aggregated'
       sequence:
         type: string
-        label: 'Selected field'
+        label: 'A property that should be part of the aggregation'
 
-plugin.plugin_configuration.search_api_processor.content_access:
+search_api.property_configuration.rendered_item:
   type: mapping
-  label: 'Content Access configuration'
+  label: 'Rendered item processor configuration'
   mapping:
-    weights:
+    roles:
       type: sequence
-      label: 'The processor''s weights for the different processing stages'
+      label: 'The selected roles'
       sequence:
-        type: integer
-        label: 'The processor''s weight for this stage'
-    fields:
+        type: string
+        label: 'The user roles which will be active when the entity is rendered'
+    view_mode:
       type: sequence
-      label: 'The selected fields'
+      label: 'The selected view modes for each datasource, by bundle'
       sequence:
-        type: string
-        label: 'Selected field'
+        type: sequence
+        label: 'The selected view modes for the datasource, by bundle'
+        sequence:
+          type: string
+          label: 'The view mode used to render the entity for the specified bundle'
diff --git a/search_api_db/search_api_db_defaults/config/optional/search_api.index.default_index.yml b/search_api_db/search_api_db_defaults/config/optional/search_api.index.default_index.yml
index d21189d..cb19e54 100644
--- a/search_api_db/search_api_db_defaults/config/optional/search_api.index.default_index.yml
+++ b/search_api_db/search_api_db_defaults/config/optional/search_api.index.default_index.yml
@@ -13,6 +13,13 @@ field_settings:
     label: 'Rendered item'
     type: text
     property_path: rendered_item
+    configuration:
+      roles:
+        anonymous: anonymous
+      view_mode:
+        'entity:node':
+          article: search_index
+          page: search_index
   created:
     label: 'Authored on'
     type: date
@@ -65,6 +72,16 @@ field_settings:
     datasource_id: 'entity:node'
     property_path: type
 processor_settings:
+  add_url:
+    plugin_id: add_url
+    settings:
+      weights:
+        preprocess_index: -30
+  aggregated_field:
+    plugin_id: aggregated_field
+    settings:
+      weights:
+        add_properties: 20
   content_access:
     plugin_id: content_access
     settings:
@@ -106,11 +123,6 @@ processor_settings:
       fields:
         - rendered_item
         - title
-  language:
-    plugin_id: language
-    settings:
-      weights:
-        preprocess_index: -50
   node_status:
     plugin_id: node_status
     settings:
@@ -120,13 +132,8 @@ processor_settings:
     plugin_id: rendered_item
     settings:
       weights:
-        preprocess_index: -8
-      roles:
-        anonymous: anonymous
-      view_mode:
-        'entity:node':
-          article: search_index
-          page: search_index
+        add_properties: 0
+        pre_index_save: -10
   stopwords:
     plugin_id: stopwords
     settings:
diff --git a/src/Entity/Index.php b/src/Entity/Index.php
index 3f910de..3810868 100644
--- a/src/Entity/Index.php
+++ b/src/Entity/Index.php
@@ -263,6 +263,13 @@ class Index extends ConfigEntityBase implements IndexInterface {
   protected $processorInstances;
 
   /**
+   * Cached property definitions, keyed by datasource ID and property name.
+   *
+   * @var \Drupal\Core\TypedData\DataDefinitionInterface[][]
+   */
+  protected $properties = array();
+
+  /**
    * Whether reindexing has been triggered for this index in this page request.
    *
    * @var bool
@@ -279,22 +286,6 @@ class Index extends ConfigEntityBase implements IndexInterface {
   /**
    * {@inheritdoc}
    */
-  public function __construct(array $values, $entity_type) {
-    parent::__construct($values, $entity_type);
-
-    // Merge in default options.
-    // @todo Use a dedicated method, like defaultConfiguration() for plugins?
-    //   And/or, better still, do this in postCreate() (and preSave()?) and not
-    //   on every load.
-    $this->options += array(
-      'cron_limit' => \Drupal::config('search_api.settings')->get('default_cron_limit'),
-      'index_directly' => TRUE,
-    );
-  }
-
-  /**
-   * {@inheritdoc}
-   */
   public function id() {
     return $this->id;
   }
@@ -597,6 +588,10 @@ public function addProcessor(ProcessorInterface $processor) {
     }
     $this->processorInstances[$processor->getPluginId()] = $processor;
 
+    if ($processor->supportsStage(ProcessorInterface::STAGE_ADD_PROPERTIES)) {
+      $this->properties = array();
+    }
+
     return $this;
   }
 
@@ -609,7 +604,14 @@ public function removeProcessor($processor_id) {
     if ($this->processorInstances === NULL) {
       $this->getProcessors();
     }
-    unset($this->processorInstances[$processor_id]);
+
+    if (!empty($this->processorInstances[$processor_id])) {
+      $processor = $this->processorInstances[$processor_id];
+      if ($processor->supportsStage(ProcessorInterface::STAGE_ADD_PROPERTIES)) {
+        $this->properties = array();
+      }
+      unset($this->processorInstances[$processor_id]);
+    }
 
     return $this;
   }
@@ -790,22 +792,31 @@ public function getFulltextFields() {
   /**
    * {@inheritdoc}
    */
-  public function getPropertyDefinitions($datasource_id, $alter = TRUE) {
-    $alter = $alter ? 1 : 0;
-    if (isset($datasource_id)) {
-      $datasource = $this->getDatasource($datasource_id);
-      $properties[$datasource_id][$alter] = $datasource->getPropertyDefinitions();
-    }
-    else {
-      $datasource = NULL;
-      $properties[$datasource_id][$alter] = array();
-    }
-    if ($alter) {
-      foreach ($this->getProcessors() as $processor) {
-        $processor->alterPropertyDefinitions($properties[$datasource_id][$alter], $datasource);
+  public function getPropertyDefinitions($datasource_id) {
+    static $recursion = FALSE;
+
+    if (!isset($this->properties[$datasource_id])) {
+      if (isset($datasource_id)) {
+        $datasource = $this->getDatasource($datasource_id);
+        $this->properties[$datasource_id] = $datasource->getPropertyDefinitions();
+      }
+      else {
+        $datasource = NULL;
+        $this->properties[$datasource_id] = array();
+      }
+
+      // We have to take care that we don't end up in an infinite loop if any
+      // processor's properties depend on the available properties on the index.
+      if (!$recursion) {
+        $recursion = TRUE;
+        foreach ($this->getProcessorsByStage(ProcessorInterface::STAGE_ADD_PROPERTIES) as $processor) {
+          $this->properties[$datasource_id] += $processor->getPropertyDefinitions($datasource);
+        }
+        $recursion = FALSE;
       }
     }
-    return $properties[$datasource_id][$alter];
+
+    return $this->properties[$datasource_id];
   }
 
   /**
@@ -1126,12 +1137,33 @@ public function query(array $options = array()) {
   /**
    * {@inheritdoc}
    */
+  public function postCreate(EntityStorageInterface $storage) {
+    parent::postCreate($storage);
+
+    // Merge in default options.
+    $config = \Drupal::config('search_api.settings');
+    $this->options += array(
+      'cron_limit' => $config->get('default_cron_limit'),
+      'index_directly' => TRUE,
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
   public function preSave(EntityStorageInterface $storage) {
     // Prevent enabling of indexes when the server is disabled.
     if ($this->status() && !$this->isServerEnabled()) {
       $this->disable();
     }
 
+    // Merge in default options.
+    $config = \Drupal::config('search_api.settings');
+    $this->options += array(
+      'cron_limit' => $config->get('default_cron_limit'),
+      'index_directly' => TRUE,
+    );
+
     // Remove all "locked" and "hidden" flags from all fields of the index. If
     // they are still valid, they should be re-added by the processors.
     foreach ($this->getFields() as $field_id => $field) {
diff --git a/src/Form/IndexProcessorsForm.php b/src/Form/IndexProcessorsForm.php
index 4f3585e..af3c61a 100644
--- a/src/Form/IndexProcessorsForm.php
+++ b/src/Form/IndexProcessorsForm.php
@@ -275,7 +275,6 @@ public function submitForm(array &$form, FormStateInterface $form_state) {
         'plugin_id' => $processor_id,
         'settings' => array(),
       );
-      $processor_values = $values['processors'][$processor_id];
       if (isset($form['settings'][$processor_id])) {
         $sub_keys = array('processors', $processor_id, 'settings');
         $processor_form_state = new SubFormState($form_state, $sub_keys);
@@ -283,8 +282,8 @@ public function submitForm(array &$form, FormStateInterface $form_state) {
         $new_settings[$processor_id]['settings'] = $processor->getConfiguration();
         $new_settings[$processor_id]['settings'] += array('index' => $this->entity);
       }
-      if (!empty($processor_values['weights'])) {
-        $new_settings[$processor_id]['settings']['weights'] = $processor_values['weights'];
+      if (!empty($values['processors'][$processor_id]['weights'])) {
+        $new_settings[$processor_id]['settings']['weights'] = $values['processors'][$processor_id]['weights'];
       }
     }
 
diff --git a/src/IndexInterface.php b/src/IndexInterface.php
index b4be269..417a303 100644
--- a/src/IndexInterface.php
+++ b/src/IndexInterface.php
@@ -446,9 +446,6 @@ public function getFulltextFields();
    * @param string|null $datasource_id
    *   The ID of the datasource for which the properties should be retrieved. Or
    *   NULL to retrieve all datasource-independent properties.
-   * @param bool $alter
-   *   (optional) Whether to pass the property definitions to the index's
-   *   enabled processors for altering before returning them.
    *
    * @return \Drupal\Core\TypedData\DataDefinitionInterface[]
    *   The properties belonging to the given datasource that are available in
@@ -458,7 +455,7 @@ public function getFulltextFields();
    *   Thrown if the specified datasource isn't enabled for this index, or
    *   couldn't be loaded.
    */
-  public function getPropertyDefinitions($datasource_id, $alter = TRUE);
+  public function getPropertyDefinitions($datasource_id);
 
   /**
    * Loads a single search object of this index.
diff --git a/src/Item/Field.php b/src/Item/Field.php
index 30a95ae..e638bf7 100644
--- a/src/Item/Field.php
+++ b/src/Item/Field.php
@@ -123,6 +123,13 @@ class Field implements \IteratorAggregate, FieldInterface {
   protected $typeLocked;
 
   /**
+   * The field's configuration.
+   *
+   * @var array
+   */
+  protected $configuration = array();
+
+  /**
    * This field's dependencies, if any.
    *
    * @var string[][]
@@ -203,6 +210,9 @@ public function getSettings() {
     if ($this->isHidden()) {
       $settings['hidden'] = TRUE;
     }
+    if ($this->getConfiguration()) {
+      $settings['configuration'] = $this->getConfiguration();
+    }
     if ($this->getDependencies()) {
       $settings['dependencies'] = $this->getDependencies();
     }
@@ -471,6 +481,21 @@ public function setTypeLocked($type_locked = TRUE) {
   /**
    * {@inheritdoc}
    */
+  public function getConfiguration() {
+    return $this->configuration;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setConfiguration(array $configuration) {
+    $this->configuration = $configuration;
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
   public function getDependencies() {
     return $this->dependencies;
   }
diff --git a/src/Item/FieldInterface.php b/src/Item/FieldInterface.php
index ef45849..7769212 100644
--- a/src/Item/FieldInterface.php
+++ b/src/Item/FieldInterface.php
@@ -342,6 +342,24 @@ public function isTypeLocked();
   public function setTypeLocked($type_locked = TRUE);
 
   /**
+   * Gets this field's property-specific configuration.
+   *
+   * @return array
+   *   An array of this field's configuration.
+   */
+  public function getConfiguration();
+
+  /**
+   * Sets this field's property-specific configuration.
+   *
+   * @param array $configuration
+   *   An associative array containing the field's configuration.
+   *
+   * @return $this
+   */
+  public function setConfiguration(array $configuration);
+
+  /**
    * Retrieves the field's dependencies.
    *
    * @return string[][]
diff --git a/src/Item/Item.php b/src/Item/Item.php
index 13afec2..1f2f4b9 100644
--- a/src/Item/Item.php
+++ b/src/Item/Item.php
@@ -4,6 +4,8 @@
 
 use Drupal\Core\TypedData\ComplexDataInterface;
 use Drupal\search_api\Datasource\DatasourceInterface;
+use Drupal\search_api\Processor\ProcessorInterface;
+use Drupal\search_api\Processor\ProcessorPropertyInterface;
 use Drupal\search_api\SearchApiException;
 use Drupal\search_api\IndexInterface;
 use Drupal\search_api\Utility;
@@ -209,6 +211,7 @@ public function getFields($extract = TRUE) {
       $data_type_fallback_mapping = Utility::getDataTypeFallbackMapping($this->index);
       foreach (array(NULL, $this->getDatasourceId()) as $datasource_id) {
         $fields_by_property_path = array();
+        $processors_with_fields = array();
         foreach ($this->index->getFieldsByDatasource($datasource_id) as $field_id => $field) {
           // Don't overwrite fields that were previously set.
           if (empty($this->fields[$field_id])) {
@@ -221,19 +224,33 @@ public function getFields($extract = TRUE) {
               $this->fields[$field_id]->setType($data_type_fallback_mapping[$field_data_type]);
             }
 
-            $fields_by_property_path[$field->getPropertyPath()][] = $this->fields[$field_id];
+            $property = $field->getDataDefinition();
+            if ($property instanceof ProcessorPropertyInterface) {
+              $processors_with_fields[$property->getProcessorId()] = TRUE;
+            }
+            elseif ($datasource_id) {
+              $fields_by_property_path[$field->getPropertyPath()][] = $this->fields[$field_id];
+            }
           }
         }
-        if ($datasource_id && $fields_by_property_path) {
-          try {
+        try {
+          if ($fields_by_property_path) {
             Utility::extractFields($this->getOriginalObject(), $fields_by_property_path);
           }
-          catch (SearchApiException $e) {
-            // If we couldn't load the object, just log an error and fail
-            // silently to set the values.
-            watchdog_exception('search_api', $e);
+          if ($processors_with_fields) {
+            $processors = $this->index->getProcessorsByStage(ProcessorInterface::STAGE_ADD_PROPERTIES);
+            foreach ($processors as $processor_id => $processor) {
+              if (isset($processors_with_fields[$processor_id])) {
+                $processor->addFieldValues($this);
+              }
+            }
           }
         }
+        catch (SearchApiException $e) {
+          // If we couldn't load the object, just log an error and fail
+          // silently to set the values.
+          watchdog_exception('search_api', $e);
+        }
       }
       $this->fieldsExtracted = TRUE;
     }
diff --git a/src/Plugin/search_api/processor/AddURL.php b/src/Plugin/search_api/processor/AddURL.php
index 022929d..fe42a9a 100644
--- a/src/Plugin/search_api/processor/AddURL.php
+++ b/src/Plugin/search_api/processor/AddURL.php
@@ -2,9 +2,10 @@
 
 namespace Drupal\search_api\Plugin\search_api\processor;
 
-use Drupal\Core\TypedData\DataDefinition;
 use Drupal\search_api\Datasource\DatasourceInterface;
+use Drupal\search_api\Item\ItemInterface;
 use Drupal\search_api\Processor\ProcessorPluginBase;
+use Drupal\search_api\Processor\ProcessorProperty;
 
 /**
  * Adds the item's URL to the indexed data.
@@ -14,10 +15,10 @@
  *   label = @Translation("URL field"),
  *   description = @Translation("Adds the item's URL to the indexed data."),
  *   stages = {
- *     "preprocess_index" = -30
+ *     "add_properties" = 0,
  *   },
  *   locked = true,
- *   hidden = true
+ *   hidden = true,
  * )
  */
 class AddURL extends ProcessorPluginBase {
@@ -25,29 +26,30 @@ class AddURL extends ProcessorPluginBase {
   /**
    * {@inheritdoc}
    */
-  public function alterPropertyDefinitions(array &$properties, DatasourceInterface $datasource = NULL) {
-    if ($datasource) {
-      return;
+  public function getPropertyDefinitions(DatasourceInterface $datasource = NULL) {
+    $properties = array();
+
+    if (!$datasource) {
+      $definition = array(
+        'label' => $this->t('URI'),
+        'description' => $this->t('A URI where the item can be accessed'),
+        'type' => 'string',
+        'processor_id' => $this->getPluginId(),
+      );
+      $properties['search_api_url'] = new ProcessorProperty($definition);
     }
-    $definition = array(
-      'label' => $this->t('URI'),
-      'description' => $this->t('A URI where the item can be accessed'),
-      'type' => 'string',
-    );
-    $properties['search_api_url'] = new DataDefinition($definition);
+
+    return $properties;
   }
 
   /**
    * {@inheritdoc}
    */
-  public function preprocessIndexItems(array &$items) {
-    // Annoyingly, this doc comment is needed for PHPStorm. See
-    // http://youtrack.jetbrains.com/issue/WI-23586
-    /** @var \Drupal\search_api\Item\ItemInterface $item */
-    foreach ($items as $item) {
-      $url = $item->getDatasource()->getItemUrl($item->getOriginalObject());
-      if ($url) {
-        foreach ($this->filterForPropertyPath($item->getFields(), 'search_api_url') as $field) {
+  public function addFieldValues(ItemInterface $item) {
+    $url = $item->getDatasource()->getItemUrl($item->getOriginalObject());
+    if ($url) {
+      foreach ($this->filterForPropertyPath($item->getFields(), 'search_api_url') as $field) {
+        if (!$field->getDatasourceId()) {
           $field->addValue($url->toString());
         }
       }
diff --git a/src/Plugin/search_api/processor/AggregatedFields.php b/src/Plugin/search_api/processor/AggregatedFields.php
index 9fc70a3..5f4a8f0 100644
--- a/src/Plugin/search_api/processor/AggregatedFields.php
+++ b/src/Plugin/search_api/processor/AggregatedFields.php
@@ -2,10 +2,12 @@
 
 namespace Drupal\search_api\Plugin\search_api\processor;
 
-use Drupal\Core\Form\FormStateInterface;
-use Drupal\Core\TypedData\DataDefinition;
 use Drupal\search_api\Datasource\DatasourceInterface;
+use Drupal\search_api\Item\ItemInterface;
+use Drupal\search_api\Plugin\search_api\processor\Property\AggregatedFieldProperty;
+use Drupal\search_api\Processor\ProcessorInterface;
 use Drupal\search_api\Processor\ProcessorPluginBase;
+use Drupal\search_api\Processor\ProcessorPropertyInterface;
 use Drupal\search_api\Utility;
 
 /**
@@ -16,9 +18,10 @@
  *   label = @Translation("Aggregated fields"),
  *   description = @Translation("Add customized aggregations of existing fields to the index."),
  *   stages = {
- *     "pre_index_save" = -10,
- *     "preprocess_index" = -25
- *   }
+ *     "add_properties" = 20,
+ *   },
+ *   locked = true,
+ *   hidden = true,
  * )
  */
 class AggregatedFields extends ProcessorPluginBase {
@@ -26,500 +29,146 @@ class AggregatedFields extends ProcessorPluginBase {
   /**
    * {@inheritdoc}
    */
-  public function defaultConfiguration() {
-    return array(
-      'fields' => array(),
-    );
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
-    $form = parent::buildConfigurationForm($form, $form_state);
-
-    $form['#attached']['library'][] = 'search_api/drupal.search_api.admin_css';
-    $form['description'] = array(
-      '#markup' => $this->t('This processor lets you define additional fields that will be added to this index. Each of these new fields will be an aggregation of one or more existing fields.<br />To add a new aggregated field, click the "Add new field" button and then fill out the form.<br />To remove a previously defined field, click the "Remove field" button.<br />You can also change the names or contained fields of existing aggregated fields.'),
-    );
-
-    $this->buildFieldsForm($form, $form_state);
-
-    $form['actions']['#type'] = 'actions';
-    $form['actions']['add'] = array(
-      '#type' => 'submit',
-      '#value' => $this->t('Add new Field'),
-      '#submit' => array(array($this, 'submitAjaxFieldButton')),
-      '#limit_validation_errors' => array(),
-      '#name' => 'add_aggregation_field',
-      '#ajax' => array(
-        'callback' => array($this, 'buildAjaxAddFieldButton'),
-        'wrapper' => 'search-api-alter-add-aggregation-field-settings',
-      ),
-    );
-
-    return $form;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  // @todo Make sure this works both with and without Javascript.
-  public function buildFieldsForm(array &$form, FormStateInterface $form_state) {
-    if (!$form_state->has('fields')) {
-      $form_state->set('fields', $this->configuration['fields']);
-    }
-    $form_state_fields = $form_state->get('fields');
-
-    // Check if we need to add a new field, or remove one.
-    $triggering_element = $form_state->getTriggeringElement();
-    if (isset($triggering_element['#name'])) {
-      drupal_set_message(t('Changes in this form will not be saved until the %button button at the form bottom is clicked.', array('%button' => t('Save'))), 'warning');
-      $button_name = $triggering_element['#name'];
-      if ($button_name == 'add_aggregation_field') {
-        // Increment $i until the corresponding field is not set, then create
-        // the field with that number as suffix.
-        for ($i = 1; isset($form_state_fields['search_api_aggregation_' . $i]); ++$i) {
-        }
-        $form_state_fields['search_api_aggregation_' . $i] = array(
-          'label' => '',
-          'type' => 'union',
-          'fields' => array(),
-        );
-      }
-      else {
-        // Get the field ID from the button name.
-        $field_id = substr($button_name, 25);
-        unset($form_state_fields[$field_id]);
-      }
-      $form_state->set('fields', $form_state_fields);
-    }
-
-    // Get index type descriptions.
-    $type_descriptions = $this->getTypeDescriptions();
-    $types = $this->getTypes();
-
-    // Get the available properties for this index.
-    $field_options = array(
-      '#type' => 'checkboxes',
-      '#title' => $this->t('Contained fields'),
-      '#options' => array(),
-      '#attributes' => array('class' => array('search-api-checkboxes-list')),
-      '#required' => TRUE,
-    );
-    $datasource_labels = $this->getDatasourceLabelPrefixes();
-    $properties = $this->getAvailableProperties();
-    ksort($properties);
-    foreach ($properties as $combined_id => $property) {
-      list($datasource_id, $name) = Utility::splitCombinedId($combined_id);
-      $field_options['#options'][$combined_id] = $datasource_labels[$datasource_id] . $property->getLabel();
-      $field_options[$combined_id] = array(
-        '#attributes' => array('title' => $this->t('Machine name: @name', array('@name' => $name))),
-        '#description' => $property->getDescription(),
-      );
-    }
-
-    $form['fields'] = array(
-      '#type' => 'container',
-      '#attributes' => array(
-        'id' => 'search-api-alter-add-aggregation-field-settings',
-      ),
-      '#tree' => TRUE,
-    );
-
-    foreach ($form_state_fields as $field_id => $field) {
-      $new = !$field['label'];
-      $form['fields'][$field_id] = array(
-        '#type' => 'details',
-        '#title' => $new ? $this->t('New field') : $field['label'],
-        '#open' => $new,
-      );
-      $form['fields'][$field_id]['label'] = array(
-        '#type' => 'textfield',
-        '#title' => $this->t('New field name'),
-        '#default_value' => $field['label'],
-        '#required' => TRUE,
-      );
-      $form['fields'][$field_id]['type'] = array(
-        '#type' => 'select',
-        '#title' => $this->t('Aggregation type'),
-        '#options' => $types,
-        '#default_value' => $field['type'],
-        '#required' => TRUE,
-      );
-
-      $form['fields'][$field_id]['type_descriptions'] = $type_descriptions;
-      foreach (array_keys($types) as $type) {
-        // @todo This shouldn't rely on undocumented form array structure.
-        $form['fields'][$field_id]['type_descriptions'][$type]['#states']['visible'][':input[name="processors[aggregated_field][settings][fields][' . $field_id . '][type]"]']['value'] = $type;
-      }
-
-      // @todo Order checked fields first in list?
-      $form['fields'][$field_id]['fields'] = $field_options;
-      $form['fields'][$field_id]['fields']['#default_value'] = $field['fields'];
-
-      $form['fields'][$field_id]['actions'] = array(
-        '#type' => 'actions',
-        'remove' => array(
-          '#type' => 'submit',
-          '#value' => $this->t('Remove field'),
-          '#submit' => array(array($this, 'submitAjaxFieldButton')),
-          '#limit_validation_errors' => array(),
-          '#name' => 'remove_aggregation_field_' . $field_id,
-          '#ajax' => array(
-            'callback' => array($this, 'buildAjaxAddFieldButton'),
-            'wrapper' => 'search-api-alter-add-aggregation-field-settings',
-          ),
-        ),
-      );
-    }
-  }
-
-  /**
-   * Retrieves form elements with the descriptions of all aggregation types.
-   *
-   * @return array
-   *   An array containing form elements with the descriptions of all
-   *   aggregation types.
-   */
-  protected function getTypeDescriptions() {
-    $form = array();
-    foreach ($this->getTypes('description') as $type => $description) {
-      $form[$type] = array(
-        '#type' => 'item',
-        '#description' => $description,
-      );
-    }
-    return $form;
-  }
-
-  /**
-   * Retrieves information about available aggregation types.
-   *
-   * @param string $info
-   *   (optional) One of "label", "type" or "description", to indicate what
-   *   values should be returned for the types.
-   *
-   * @return array
-   *   An array of the identifiers of the available types mapped to, depending
-   *   on $info, their labels, their data types or their descriptions.
-   */
-  protected function getTypes($info = 'label') {
-    switch ($info) {
-      case 'label':
-        return array(
-          'union' => $this->t('Union'),
-          'concat' => $this->t('Concatenation'),
-          'sum' => $this->t('Sum'),
-          'count' => $this->t('Count'),
-          'max' => $this->t('Maximum'),
-          'min' => $this->t('Minimum'),
-          'first' => $this->t('First'),
-        );
-
-      case 'type':
-        return array(
-          'union' => 'string',
-          'concat' => 'string',
-          'sum' => 'integer',
-          'count' => 'integer',
-          'max' => 'integer',
-          'min' => 'integer',
-          'first' => 'string',
-        );
-
-      case 'description':
-        return array(
-          'union' => $this->t('The Union aggregation does an union operation of all the values of the field. 2 fields with 2 values each become 1 field with 4 values.'),
-          'concat' => $this->t('The Concatenation aggregation concatenates the text data of all contained fields.'),
-          'sum' => $this->t('The Sum aggregation adds the values of all contained fields numerically.'),
-          'count' => $this->t('The Count aggregation takes the total number of contained field values as the aggregated field value.'),
-          'max' => $this->t('The Maximum aggregation computes the numerically largest contained field value.'),
-          'min' => $this->t('The Minimum aggregation computes the numerically smallest contained field value.'),
-          'first' => $this->t('The First aggregation will simply keep the first encountered field value.'),
-        );
-
-    }
-    return array();
-  }
-
-  /**
-   * Retrieves label prefixes for this index's datasources.
-   *
-   * @return string[]
-   *   An associative array mapping datasource IDs (and an empty string for
-   *   datasource-independent properties) to their label prefixes.
-   */
-  protected function getDatasourceLabelPrefixes() {
-    $prefixes = array(
-      NULL => $this->t('General') . ' » ',
-    );
-
-    foreach ($this->index->getDatasources() as $datasource_id => $datasource) {
-      $prefixes[$datasource_id] = $datasource->label() . ' » ';
-    }
-
-    return $prefixes;
-  }
-
-  /**
-   * Retrieve all properties available on the index.
-   *
-   * The properties will be keyed by combined ID, which is a combination of the
-   * datasource ID and the property path. This is used internally in this class
-   * to easily identify any property on the index.
-   *
-   * @param bool $alter
-   *   (optional) Whether to pass the property definitions to the index's
-   *   enabled processors for altering before returning them. Must be set to
-   *   FALSE when called from within alterProperties(), for obvious reasons.
-   *
-   * @return \Drupal\Core\TypedData\DataDefinitionInterface[]
-   *   All the properties available on the index, keyed by combined ID.
-   *
-   * @see \Drupal\search_api\Utility::createCombinedId()
-   */
-  protected function getAvailableProperties($alter = TRUE) {
+  public function getPropertyDefinitions(DatasourceInterface $datasource = NULL) {
     $properties = array();
 
-    $datasource_ids = $this->index->getDatasourceIds();
-    $datasource_ids[] = NULL;
-    foreach ($datasource_ids as $datasource_id) {
-      foreach ($this->index->getPropertyDefinitions($datasource_id, $alter) as $property_path => $property) {
-        $properties[Utility::createCombinedId($datasource_id, $property_path)] = $property;
-      }
+    if (!$datasource) {
+      $definition = array(
+        'label' => $this->t('Aggregated field'),
+        'description' => $this->t('An aggregation of multiple other fields.'),
+        'type' => 'string',
+        'processor_id' => $this->getPluginId(),
+      );
+      $properties['aggregated_field'] = new AggregatedFieldProperty($definition);
     }
 
     return $properties;
   }
 
   /**
-   * Form submission handler for this processor form's AJAX buttons.
-   */
-  public static function submitAjaxFieldButton(array $form, FormStateInterface $form_state) {
-    $form_state->setRebuild();
-  }
-
-  /**
-   * Handles adding or removing of aggregated fields via AJAX.
-   */
-  public static function buildAjaxAddFieldButton(array $form, FormStateInterface $form_state) {
-    return $form['settings']['aggregated_field']['fields'];
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
-    $values = $form_state->getValues();
-    if (empty($values['fields'])) {
-      return;
-    }
-    foreach ($values['fields'] as $field_id => &$field) {
-      if ($field['label'] && !$field['fields']) {
-        $error_message = $this->t('You have to select at least one field to aggregate.');
-        $form_state->setError($form['fields'][$field_id]['fields'], $error_message);
-      }
-    }
-  }
-
-  /**
    * {@inheritdoc}
    */
-  public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
-    $values = $form_state->getValues();
-
-    // Remove the unnecessary form_state values, so no overhead is stored.
-    unset($values['actions']);
-    if (!empty($values['fields'])) {
-      foreach ($values['fields'] as &$field_definition) {
-        unset($field_definition['type_descriptions'], $field_definition['actions']);
-        $field_definition['fields'] = array_values(array_filter($field_definition['fields']));
-      }
-    }
-    else {
-      $values['fields'] = array();
-    }
-
-    $form_state->setValues($values);
-    parent::submitConfigurationForm($form, $form_state);
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function preIndexSave() {
-    foreach ($this->configuration['fields'] as $field_id => $field_definition) {
-      $this->ensureField(NULL, $field_id);
-    }
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function preprocessIndexItems(array &$items) {
-    if (!$items || empty($this->configuration['fields'])) {
-      return;
-    }
-
-    $label_not_empty = function (array $field_definition) {
-      return !empty($field_definition['label']);
-    };
-    $aggregated_fields = array_filter($this->configuration['fields'], $label_not_empty);
-    if (!$aggregated_fields) {
-      return;
-    }
-
-    $required_properties_by_datasource = array_fill_keys($this->index->getDatasourceIds(), array());
-    $required_properties_by_datasource[NULL] = array();
-    foreach ($aggregated_fields as $field_definition) {
-      foreach ($field_definition['fields'] as $combined_id) {
+  public function addFieldValues(ItemInterface $item) {
+    $aggregated_fields = $this->filterForPropertyPath(
+      $this->index->getFieldsByDatasource(NULL),
+      'aggregated_field'
+    );
+    $required_properties_by_datasource = array(
+      NULL => array(),
+      $item->getDatasourceId() => array(),
+    );
+    foreach ($aggregated_fields as $field) {
+      foreach ($field->getConfiguration()['fields'] as $combined_id) {
         list($datasource_id, $property_path) = Utility::splitCombinedId($combined_id);
         $required_properties_by_datasource[$datasource_id][$property_path] = $combined_id;
       }
     }
 
-    /** @var \Drupal\search_api\Item\ItemInterface[] $items */
-    foreach ($items as $item) {
-      // Extract the required properties.
-      $property_values = array();
-      /** @var \Drupal\search_api\Item\FieldInterface[][] $missing_fields */
-      $missing_fields = array();
-      foreach (array(NULL, $item->getDatasourceId()) as $datasource_id) {
-        foreach ($required_properties_by_datasource[$datasource_id] as $property_path => $combined_id) {
-          // If a field with the right property path is already set on the item,
-          // use it. This might actually make problems in case the values have
-          // already been processed in some way, or use a data type that
-          // transformed their original value – but on the other hand, it's
-          // (currently – see #2575003) the only way to include computed
-          // (processor-added) properties here, so it seems like a fair
-          // trade-off.
-          foreach ($this->filterForPropertyPath($item->getFields(FALSE), $property_path) as $field) {
-            if ($field->getDatasourceId() === $datasource_id) {
-              $property_values[$combined_id] = $field->getValues();
-              continue 2;
-            }
+    // Extract the required properties.
+    $property_values = array();
+    /** @var \Drupal\search_api\Item\FieldInterface[][] $missing_fields */
+    $missing_fields = array();
+    $processor_fields = array();
+    $needed_processors = array();
+    foreach (array(NULL, $item->getDatasourceId()) as $datasource_id) {
+      $properties = $this->index->getPropertyDefinitions($datasource_id);
+      foreach ($required_properties_by_datasource[$datasource_id] as $property_path => $combined_id) {
+        // If a field with the right property path is already set on the item,
+        // use it. This might actually make problems in case the values have
+        // already been processed in some way, or use a data type that
+        // transformed their original value. But that will hopefully not be a
+        // problem in most situations.
+        foreach ($this->filterForPropertyPath($item->getFields(FALSE), $property_path) as $field) {
+          if ($field->getDatasourceId() === $datasource_id) {
+            $property_values[$combined_id] = $field->getValues();
+            continue 2;
           }
+        }
 
-          // If the field is not already on the item, we need to extract it. We
-          // set our own combined ID as the field identifier as kind of a hack,
-          // to easily be able to add the field values to $property_values
-          // afterwards.
-          if ($datasource_id) {
-            $missing_fields[$property_path][] = Utility::createField($this->index, $combined_id);
-          }
-          else {
-            // Extracting properties without a datasource is pointless.
-            $property_values[$combined_id] = array();
-          }
+        // If the field is not already on the item, we need to extract it. We
+        // set our own combined ID as the field identifier as kind of a hack,
+        // to easily be able to add the field values to $property_values
+        // afterwards.
+        $property = NULL;
+        if (isset($properties[$property_path])) {
+          $property = $properties[$property_path];
+        }
+        if ($property instanceof ProcessorPropertyInterface) {
+          $processor_fields[] = Utility::createField($this->index, $combined_id);
+          $needed_processors[$property->getProcessorId()] = TRUE;
+        }
+        elseif ($datasource_id) {
+          $missing_fields[$property_path][] = Utility::createField($this->index, $combined_id);
+        }
+        else {
+          // Extracting properties without a datasource is pointless.
+          $property_values[$combined_id] = array();
         }
       }
-      if ($missing_fields) {
-        Utility::extractFields($item->getOriginalObject(), $missing_fields);
-        foreach ($missing_fields as $property_fields) {
-          foreach ($property_fields as $field) {
-            $property_values[$field->getFieldIdentifier()] = $field->getValues();
-          }
+    }
+    if ($missing_fields) {
+      Utility::extractFields($item->getOriginalObject(), $missing_fields);
+      foreach ($missing_fields as $property_fields) {
+        foreach ($property_fields as $field) {
+          $property_values[$field->getFieldIdentifier()] = $field->getValues();
         }
       }
-
-      foreach ($this->configuration['fields'] as $aggregated_field_id => $aggregated_field) {
-        $values = array();
-        foreach ($aggregated_field['fields'] as $combined_id) {
-          if (!empty($property_values[$combined_id])) {
-            $values = array_merge($values, $property_values[$combined_id]);
-          }
+    }
+    if ($processor_fields) {
+      $dummy_item = clone $item;
+      $dummy_item->setFields($processor_fields);
+      $processors = $this->index->getProcessorsByStage(ProcessorInterface::STAGE_ADD_PROPERTIES);
+      foreach ($processors as $processor_id => $processor) {
+        // Avoid an infinite recursion.
+        if (isset($needed_processors[$processor_id]) && $processor != $this) {
+          $processor->addFieldValues($dummy_item);
         }
+      }
+      foreach ($processor_fields as $field) {
+        $property_values[$field->getFieldIdentifier()] = $field->getValues();
+      }
+    }
 
-        switch ($aggregated_field['type']) {
-          case 'concat':
-            $values = array(implode("\n\n", $values));
-            break;
+    $aggregated_fields = $this->filterForPropertyPath($item->getFields(), 'aggregated_field');
+    foreach ($aggregated_fields as $aggregated_field) {
+      $values = array();
+      $configuration = $aggregated_field->getConfiguration();
+      foreach ($configuration['fields'] as $combined_id) {
+        if (!empty($property_values[$combined_id])) {
+          $values = array_merge($values, $property_values[$combined_id]);
+        }
+      }
 
-          case 'sum':
-            $values = array(array_sum($values));
-            break;
+      switch ($configuration['type']) {
+        case 'concat':
+          $values = array(implode("\n\n", $values));
+          break;
 
-          case 'count':
-            $values = array(count($values));
-            break;
+        case 'sum':
+          $values = array(array_sum($values));
+          break;
 
-          case 'max':
-            $values = array(max($values));
-            break;
+        case 'count':
+          $values = array(count($values));
+          break;
 
-          case 'min':
-            $values = array(min($values));
-            break;
+        case 'max':
+          $values = array(max($values));
+          break;
 
-          case 'first':
-            if ($values) {
-              $values = array(reset($values));
-            }
-            break;
+        case 'min':
+          $values = array(min($values));
+          break;
 
-        }
-
-        if ($values) {
-          foreach ($this->filterForPropertyPath($item->getFields(), $aggregated_field_id) as $field) {
-            $field->setValues($values);
+        case 'first':
+          if ($values) {
+            $values = array(reset($values));
           }
-        }
+          break;
       }
-    }
-  }
 
-  /**
-   * {@inheritdoc}
-   */
-  public function alterPropertyDefinitions(array &$properties, DatasourceInterface $datasource = NULL) {
-    if ($datasource) {
-      return;
+      $aggregated_field->setValues($values);
     }
-
-    $types = $this->getTypes('type');
-    if (!empty($this->configuration['fields'])) {
-      // Collect all available properties, keyed by combined ID.
-      $available_properties = $this->getAvailableProperties(FALSE);
-      $datasource_label_prefixes = $this->getDatasourceLabelPrefixes();
-      foreach ($this->configuration['fields'] as $aggregated_field_id => $field_definition) {
-        $definition = array(
-          'label' => $field_definition['label'],
-          'description' => $this->fieldDescription($field_definition, $available_properties, $datasource_label_prefixes),
-          'type' => $types[$field_definition['type']],
-        );
-        $properties[$aggregated_field_id] = new DataDefinition($definition);
-      }
-    }
-  }
-
-  /**
-   * Creates a description for an aggregated field.
-   *
-   * @param array $field_definition
-   *   The settings of the aggregated field.
-   * @param \Drupal\Core\TypedData\DataDefinitionInterface[] $properties
-   *   All available properties on the index, keyed by combined ID.
-   * @param string[] $datasource_label_prefixes
-   *   The label prefixes for all datasources.
-   *
-   * @return string
-   *   A description for the given aggregated field.
-   */
-  protected function fieldDescription(array $field_definition, array $properties, array $datasource_label_prefixes) {
-    $fields = array();
-    foreach ($field_definition['fields'] as $combined_id) {
-      list($datasource_id, $property_path) = Utility::splitCombinedId($combined_id);
-      $label = $property_path;
-      if (isset($properties[$combined_id])) {
-        $label = $properties[$combined_id]->getLabel();
-      }
-      $fields[] = $datasource_label_prefixes[$datasource_id] . $label;
-    }
-    $type = $this->getTypes()[$field_definition['type']];
-
-    $arguments = array('@type' => $type, '@fields' => implode(', ', $fields));
-    return $this->t('A @type aggregation of the following fields: @fields.', $arguments);
   }
 
 }
diff --git a/src/Plugin/search_api/processor/ContentAccess.php b/src/Plugin/search_api/processor/ContentAccess.php
index bae30b5..965efa8 100644
--- a/src/Plugin/search_api/processor/ContentAccess.php
+++ b/src/Plugin/search_api/processor/ContentAccess.php
@@ -3,14 +3,15 @@
 namespace Drupal\search_api\Plugin\search_api\processor;
 
 use Drupal\comment\CommentInterface;
-use Drupal\Core\Database\Database;
+use Drupal\Core\Database\Connection;
 use Drupal\Core\Logger\LoggerChannelInterface;
 use Drupal\Core\Session\AccountInterface;
 use Drupal\Core\Session\AnonymousUserSession;
 use Drupal\Core\TypedData\ComplexDataInterface;
-use Drupal\Core\TypedData\DataDefinition;
 use Drupal\node\NodeInterface;
 use Drupal\search_api\Datasource\DatasourceInterface;
+use Drupal\search_api\Item\ItemInterface;
+use Drupal\search_api\Processor\ProcessorProperty;
 use Drupal\search_api\SearchApiException;
 use Drupal\search_api\IndexInterface;
 use Drupal\search_api\Processor\ProcessorPluginBase;
@@ -26,15 +27,22 @@
  *   label = @Translation("Content access"),
  *   description = @Translation("Adds content access checks for nodes and comments."),
  *   stages = {
+ *     "add_properties" = 0,
  *     "pre_index_save" = -10,
- *     "preprocess_index" = -30,
- *     "preprocess_query" = -30
- *   }
+ *     "preprocess_query" = -30,
+ *   },
  * )
  */
 class ContentAccess extends ProcessorPluginBase {
 
   /**
+   * The database connection.
+   *
+   * @var \Drupal\Core\Database\Connection|null
+   */
+  protected $database;
+
+  /**
    * The logger to use for logging messages.
    *
    * @var \Drupal\Core\Logger\LoggerChannelInterface|null
@@ -48,14 +56,35 @@ public static function create(ContainerInterface $container, array $configuratio
     /** @var static $processor */
     $processor = parent::create($container, $configuration, $plugin_id, $plugin_definition);
 
-    /** @var \Drupal\Core\Logger\LoggerChannelInterface $logger */
-    $logger = $container->get('logger.factory')->get('search_api');
-    $processor->setLogger($logger);
+    $processor->setLogger($container->get('logger.factory')->get('search_api'));
+    $processor->setDatabase($container->get('database'));
 
     return $processor;
   }
 
   /**
+   * Retrieves the database connection.
+   *
+   * @return \Drupal\Core\Database\Connection
+   *   The database connection.
+   */
+  public function getDatabase() {
+    return $this->database ?: \Drupal::database();
+  }
+
+  /**
+   * Sets the database connection.
+   *
+   * @param \Drupal\Core\Database\Connection $database
+   *   The new database connection.
+   *
+   * @return $this
+   */
+  public function setDatabase(Connection $database) {
+    $this->database = $database;
+    return $this;
+  }
+  /**
    * Retrieves the logger to use.
    *
    * @return \Drupal\Core\Logger\LoggerChannelInterface
@@ -90,38 +119,27 @@ public static function supportsIndex(IndexInterface $index) {
   /**
    * {@inheritdoc}
    */
-  public function alterPropertyDefinitions(array &$properties, DatasourceInterface $datasource = NULL) {
-    $definition = array(
-      'label' => $this->t('Node access information'),
-      'description' => $this->t('Data needed to apply node access.'),
-      'type' => 'string',
-    );
-    $properties['search_api_node_grants'] = new DataDefinition($definition);
-  }
+  public function getPropertyDefinitions(DatasourceInterface $datasource = NULL) {
+    $properties = array();
 
-  /**
-   * {@inheritdoc}
-   */
-  public function preIndexSave() {
-    foreach ($this->index->getDatasources() as $datasource_id => $datasource) {
-      $entity_type = $datasource->getEntityTypeId();
-      if (in_array($entity_type, array('node', 'comment'))) {
-        $this->ensureField($datasource_id, 'status', 'boolean');
-        if ($entity_type == 'node') {
-          $this->ensureField($datasource_id, 'uid', 'integer');
-        }
-      }
+    if (!$datasource) {
+      $definition = array(
+        'label' => $this->t('Node access information'),
+        'description' => $this->t('Data needed to apply node access.'),
+        'type' => 'string',
+        'processor_id' => $this->getPluginId(),
+        'hidden' => TRUE,
+      );
+      $properties['search_api_node_grants'] = new ProcessorProperty($definition);
     }
 
-    $field = $this->ensureField(NULL, 'search_api_node_grants', 'string');
-    $field->setHidden();
-    $this->index->addField($field);
+    return $properties;
   }
 
   /**
    * {@inheritdoc}
    */
-  public function preprocessIndexItems(array &$items) {
+  public function addFieldValues(ItemInterface $item) {
     static $anonymous_user;
 
     if (!isset($anonymous_user)) {
@@ -129,40 +147,54 @@ public function preprocessIndexItems(array &$items) {
       $anonymous_user = new AnonymousUserSession();
     }
 
-    // Annoyingly, this doc comment is needed for PHPStorm. See
-    // http://youtrack.jetbrains.com/issue/WI-23586
-    /** @var \Drupal\search_api\Item\ItemInterface $item */
-    foreach ($items as $item) {
-      // Only run for node and comment items.
-      if (!in_array($item->getDatasource()->getEntityTypeId(), array('node', 'comment'))) {
-        continue;
-      }
+    // Only run for node and comment items.
+    $entity_type_id = $item->getDatasource()->getEntityTypeId();
+    if (!in_array($entity_type_id, array('node', 'comment'))) {
+      return;
+    }
 
-      // Get the node object.
-      $node = $this->getNode($item->getOriginalObject());
-      if (!$node) {
-        // Apparently we were active for a wrong item.
-        continue;
-      }
+    // Get the node object.
+    $node = $this->getNode($item->getOriginalObject());
+    if (!$node) {
+      // Apparently we were active for a wrong item.
+      return;
+    }
 
-      foreach ($this->filterForPropertyPath($item->getFields(), 'search_api_node_grants') as $field) {
-        // Collect grant information for the node.
-        if (!$node->access('view', $anonymous_user)) {
-          // If anonymous user has no permission we collect all grants with
-          // their realms in the item.
-          $result = Database::getConnection()
-            ->query('SELECT * FROM {node_access} WHERE (nid = 0 OR nid = :nid) AND grant_view = 1', array(':nid' => $node->id()));
-          foreach ($result as $grant) {
-            $field->addValue("node_access_{$grant->realm}:{$grant->gid}");
-          }
+    foreach ($this->filterForPropertyPath($item->getFields(), 'search_api_node_grants') as $field) {
+      // Collect grant information for the node.
+      if (!$node->access('view', $anonymous_user)) {
+        // If anonymous user has no permission we collect all grants with
+        // their realms in the item.
+        $sql = 'SELECT * FROM {node_access} WHERE (nid = 0 OR nid = :nid) AND grant_view = 1';
+        $args = array(':nid' => $node->id());
+        foreach ($this->getDatabase()->query($sql, $args) as $grant) {
+          $field->addValue("node_access_{$grant->realm}:{$grant->gid}");
         }
-        else {
-          // Add the generic pseudo view grant if we are not using node access
-          // or the node is viewable by anonymous users.
-          $field->addValue('node_access__all');
+      }
+      else {
+        // Add the generic pseudo view grant if we are not using node access
+        // or the node is viewable by anonymous users.
+        $field->addValue('node_access__all');
+      }
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function preIndexSave() {
+    foreach ($this->index->getDatasources() as $datasource_id => $datasource) {
+      $entity_type = $datasource->getEntityTypeId();
+      if (in_array($entity_type, array('node', 'comment'))) {
+        $this->ensureField($datasource_id, 'status', 'boolean');
+        if ($entity_type == 'node') {
+          $this->ensureField($datasource_id, 'uid', 'integer');
         }
       }
     }
+
+    $field = $this->ensureField(NULL, 'search_api_node_grants', 'string');
+    $field->setHidden();
   }
 
   /**
@@ -197,7 +229,7 @@ public function preprocessSearchQuery(QueryInterface $query) {
       if (is_numeric($account)) {
         $account = User::load($account);
       }
-      if (is_object($account)) {
+      if ($account instanceof AccountInterface) {
         try {
           $this->addNodeAccess($query, $account);
         }
diff --git a/src/Plugin/search_api/processor/Property/AggregatedFieldProperty.php b/src/Plugin/search_api/processor/Property/AggregatedFieldProperty.php
new file mode 100644
index 0000000..16ea9f5
--- /dev/null
+++ b/src/Plugin/search_api/processor/Property/AggregatedFieldProperty.php
@@ -0,0 +1,178 @@
+<?php
+
+namespace Drupal\search_api\Plugin\search_api\processor\Property;
+
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\search_api\IndexInterface;
+use Drupal\search_api\Item\FieldInterface;
+use Drupal\search_api\Processor\ConfigurablePropertyBase;
+use Drupal\search_api\Utility;
+
+/**
+ * Defines an "aggregated field" property.
+ */
+class AggregatedFieldProperty extends ConfigurablePropertyBase {
+
+  use StringTranslationTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function defaultConfiguration() {
+    return array(
+      'type' => 'union',
+      'fields' => array(),
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildConfigurationForm(FieldInterface $field, array $form, FormStateInterface $form_state) {
+    $index = $field->getIndex();
+    $configuration = $field->getConfiguration();
+
+    $form['#attached']['library'][] = 'search_api/drupal.search_api.admin_css';
+
+    $form['type'] = array(
+      '#type' => 'select',
+      '#title' => $this->t('Aggregation type'),
+      '#options' => $this->getTypes(),
+      '#default_value' => $configuration['type'],
+      '#required' => TRUE,
+    );
+
+    foreach ($this->getTypes('description') as $type => $description) {
+      $form['type_descriptions'][$type] = array(
+        '#type' => 'item',
+        '#description' => $description,
+      );
+      $form['type_descriptions'][$type]['#states']['visible'][':input[name="type"]']['value'] = $type;
+    }
+
+    $form['fields'] = array(
+      '#type' => 'checkboxes',
+      '#title' => $this->t('Contained fields'),
+      '#options' => array(),
+      '#attributes' => array('class' => array('search-api-checkboxes-list')),
+      '#default_value' => $configuration['fields'],
+      '#required' => TRUE,
+    );
+    $datasource_labels = $this->getDatasourceLabelPrefixes($index);
+    $properties = $this->getAvailableProperties($index);
+    ksort($properties);
+    foreach ($properties as $combined_id => $property) {
+      list($datasource_id, $name) = Utility::splitCombinedId($combined_id);
+      $form['fields']['#options'][$combined_id] = $datasource_labels[$datasource_id] . $property->getLabel();
+      $form['fields'][$combined_id] = array(
+        '#attributes' => array('title' => $this->t('Machine name: @name', array('@name' => $name))),
+        '#description' => $property->getDescription(),
+      );
+    }
+
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitConfigurationForm(FieldInterface $field, array &$form, FormStateInterface $form_state) {
+    $values = array(
+      'type' => $form_state->getValue('type'),
+      'fields' => array_keys(array_filter($form_state->getValue('fields'))),
+    );
+    $field->setConfiguration($values);
+  }
+
+  /**
+   * Retrieves information about available aggregation types.
+   *
+   * @param string $info
+   *   (optional) One of "label" or "description", to indicate what values
+   *   should be returned for the types.
+   *
+   * @return array
+   *   An array of the identifiers of the available types mapped to, depending
+   *   on $info, their labels, their data types or their descriptions.
+   */
+  protected function getTypes($info = 'label') {
+    switch ($info) {
+      case 'label':
+        return array(
+          'union' => $this->t('Union'),
+          'concat' => $this->t('Concatenation'),
+          'sum' => $this->t('Sum'),
+          'count' => $this->t('Count'),
+          'max' => $this->t('Maximum'),
+          'min' => $this->t('Minimum'),
+          'first' => $this->t('First'),
+        );
+
+      case 'description':
+        return array(
+          'union' => $this->t('The Union aggregation does an union operation of all the values of the field. 2 fields with 2 values each become 1 field with 4 values.'),
+          'concat' => $this->t('The Concatenation aggregation concatenates the text data of all contained fields.'),
+          'sum' => $this->t('The Sum aggregation adds the values of all contained fields numerically.'),
+          'count' => $this->t('The Count aggregation takes the total number of contained field values as the aggregated field value.'),
+          'max' => $this->t('The Maximum aggregation computes the numerically largest contained field value.'),
+          'min' => $this->t('The Minimum aggregation computes the numerically smallest contained field value.'),
+          'first' => $this->t('The First aggregation will simply keep the first encountered field value.'),
+        );
+
+    }
+    return array();
+  }
+
+  /**
+   * Retrieves label prefixes for an index's datasources.
+   *
+   * @param \Drupal\search_api\IndexInterface $index
+   *   The search index.
+   *
+   * @return string[]
+   *   An associative array mapping datasource IDs (and an empty string for
+   *   datasource-independent properties) to their label prefixes.
+   */
+  protected function getDatasourceLabelPrefixes(IndexInterface $index) {
+    $prefixes = array(
+      NULL => $this->t('General') . ' » ',
+    );
+
+    foreach ($index->getDatasources() as $datasource_id => $datasource) {
+      $prefixes[$datasource_id] = $datasource->label() . ' » ';
+    }
+
+    return $prefixes;
+  }
+
+  /**
+   * Retrieve all properties available on the index.
+   *
+   * The properties will be keyed by combined ID, which is a combination of the
+   * datasource ID and the property path. This is used internally in this class
+   * to easily identify any property on the index.
+   *
+   * @param \Drupal\search_api\IndexInterface $index
+   *   The search index.
+   *
+   * @return \Drupal\Core\TypedData\DataDefinitionInterface[]
+   *   All the properties available on the index, keyed by combined ID.
+   *
+   * @see \Drupal\search_api\Utility::createCombinedId()
+   */
+  protected function getAvailableProperties(IndexInterface $index) {
+    $properties = array();
+
+    $datasource_ids = $index->getDatasourceIds();
+    $datasource_ids[] = NULL;
+    foreach ($datasource_ids as $datasource_id) {
+      foreach ($index->getPropertyDefinitions($datasource_id) as $property_path => $property) {
+        $properties[Utility::createCombinedId($datasource_id, $property_path)] = $property;
+      }
+    }
+
+    return $properties;
+  }
+
+}
diff --git a/src/Plugin/search_api/processor/Property/RenderedItemProperty.php b/src/Plugin/search_api/processor/Property/RenderedItemProperty.php
new file mode 100644
index 0000000..4062c06
--- /dev/null
+++ b/src/Plugin/search_api/processor/Property/RenderedItemProperty.php
@@ -0,0 +1,86 @@
+<?php
+
+namespace Drupal\search_api\Plugin\search_api\processor\Property;
+
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\search_api\Item\FieldInterface;
+use Drupal\search_api\Processor\ConfigurablePropertyBase;
+
+/**
+ * Defines a "rendered item" property.
+ */
+class RenderedItemProperty extends ConfigurablePropertyBase {
+
+  use StringTranslationTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function defaultConfiguration() {
+    return array(
+      'roles' => array(AccountInterface::ANONYMOUS_ROLE),
+      'view_mode' => array(),
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildConfigurationForm(FieldInterface $field, array $form, FormStateInterface $form_state) {
+    $configuration = $field->getConfiguration();
+    $index = $field->getIndex();
+
+    $roles = user_role_names();
+    $form['roles'] = array(
+      '#type' => 'select',
+      '#title' => $this->t('User roles'),
+      '#description' => $this->t('Your item will be rendered as seen by a user with the selected roles. We recommend to just use "@anonymous" here to prevent data leaking out to unauthorized roles.', array('@anonymous' => $roles[AccountInterface::ANONYMOUS_ROLE])),
+      '#options' => $roles,
+      '#multiple' => TRUE,
+      '#default_value' => $configuration['roles'],
+      '#required' => TRUE,
+    );
+
+    $form['view_mode'] = array(
+      '#type' => 'item',
+      '#description' => $this->t('You can choose the view modes to use for rendering the items of different datasources and bundles. We recommend using a dedicated view mode (e.g., the "Search index" view mode available by default for content) to make sure that only relevant data (especially no field labels) will be included in the index.'),
+    );
+
+    $options_present = FALSE;
+    foreach ($index->getDatasources() as $datasource_id => $datasource) {
+      $bundles = $datasource->getBundles();
+      foreach ($bundles as $bundle_id => $bundle_label) {
+        $view_modes = $datasource->getViewModes($bundle_id);
+        $view_modes[''] = $this->t("Don't include the rendered item.");
+        if (count($view_modes) > 1) {
+          $form['view_mode'][$datasource_id][$bundle_id] = array(
+            '#type' => 'select',
+            '#title' => $this->t('View mode for %datasource » %bundle', array('%datasource' => $datasource->label(), '%bundle' => $bundle_label)),
+            '#options' => $view_modes,
+          );
+          if (isset($configuration['view_mode'][$datasource_id][$bundle_id])) {
+            $form['view_mode'][$datasource_id][$bundle_id]['#default_value'] = $configuration['view_mode'][$datasource_id][$bundle_id];
+          }
+          $options_present = TRUE;
+        }
+        else {
+          $form['view_mode'][$datasource_id][$bundle_id] = array(
+            '#type' => 'value',
+            '#value' => $view_modes ? key($view_modes) : FALSE,
+          );
+        }
+      }
+    }
+    // If there are no datasources/bundles with more than one view mode, don't
+    // display the description either.
+    if (!$options_present) {
+      unset($form['view_mode']['#type']);
+      unset($form['view_mode']['#description']);
+    }
+
+    return $form;
+  }
+
+}
diff --git a/src/Plugin/search_api/processor/RenderedItem.php b/src/Plugin/search_api/processor/RenderedItem.php
index 21427cd..e59487b 100644
--- a/src/Plugin/search_api/processor/RenderedItem.php
+++ b/src/Plugin/search_api/processor/RenderedItem.php
@@ -3,13 +3,12 @@
 namespace Drupal\search_api\Plugin\search_api\processor;
 
 use Drupal\Core\Entity\Entity\EntityViewMode;
-use Drupal\Core\Form\FormStateInterface;
 use Drupal\Core\Render\RendererInterface;
-use Drupal\Core\Session\AccountInterface;
 use Drupal\Core\Session\AccountProxyInterface;
 use Drupal\Core\Session\UserSession;
-use Drupal\Core\TypedData\DataDefinition;
 use Drupal\search_api\Datasource\DatasourceInterface;
+use Drupal\search_api\Item\ItemInterface;
+use Drupal\search_api\Plugin\search_api\processor\Property\RenderedItemProperty;
 use Drupal\search_api\Processor\ProcessorPluginBase;
 use Psr\Log\LoggerInterface;
 use Symfony\Component\DependencyInjection\ContainerInterface;
@@ -22,9 +21,11 @@
  *   label = @Translation("Rendered item"),
  *   description = @Translation("Adds an additional field containing the rendered item as it would look when viewed."),
  *   stages = {
+ *     "add_properties" = 0,
  *     "pre_index_save" = -10,
- *     "preprocess_index" = -30
- *   }
+ *   },
+ *   locked = true,
+ *   hidden = true,
  * )
  */
 class RenderedItem extends ProcessorPluginBase {
@@ -147,144 +148,72 @@ public function setLogger(LoggerInterface $logger) {
   /**
    * {@inheritdoc}
    */
-  public function defaultConfiguration() {
-    return array(
-      'roles' => array(AccountInterface::ANONYMOUS_ROLE),
-      'view_mode' => array(),
-    );
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
-    $form = parent::buildConfigurationForm($form, $form_state);
-
-    $roles = user_role_names();
-    $form['roles'] = array(
-      '#type' => 'select',
-      '#title' => $this->t('User roles'),
-      '#description' => $this->t('Your item will be rendered as seen by a user with the selected roles. We recommend to just use "@anonymous" here to prevent data leaking out to unauthorized roles.', array('@anonymous' => $roles[AccountInterface::ANONYMOUS_ROLE])),
-      '#options' => $roles,
-      '#multiple' => TRUE,
-      '#default_value' => $this->configuration['roles'],
-      '#required' => TRUE,
-    );
-
-    $form['view_mode'] = array(
-      '#type' => 'item',
-      '#description' => $this->t('You can choose the view modes to use for rendering the items of different datasources and bundles. We recommend using a dedicated view mode (e.g., the "Search index" view mode available by default for content) to make sure that only relevant data (especially no field labels) will be included in the index.'),
-    );
-
-    $options_present = FALSE;
-    foreach ($this->index->getDatasources() as $datasource_id => $datasource) {
-      $bundles = $datasource->getBundles();
-      foreach ($bundles as $bundle_id => $bundle_label) {
-        $view_modes = $datasource->getViewModes($bundle_id);
-        $view_modes[''] = $this->t("Don't include the rendered item.");
-        if (count($view_modes) > 1) {
-          $form['view_mode'][$datasource_id][$bundle_id] = array(
-            '#type' => 'select',
-            '#title' => $this->t('View mode for %datasource » %bundle', array('%datasource' => $datasource->label(), '%bundle' => $bundle_label)),
-            '#options' => $view_modes,
-          );
-          if (isset($this->configuration['view_mode'][$datasource_id][$bundle_id])) {
-            $form['view_mode'][$datasource_id][$bundle_id]['#default_value'] = $this->configuration['view_mode'][$datasource_id][$bundle_id];
-          }
-          $options_present = TRUE;
-        }
-        else {
-          $form['view_mode'][$datasource_id][$bundle_id] = array(
-            '#type' => 'value',
-            '#value' => $view_modes ? key($view_modes) : FALSE,
-          );
-        }
-      }
-    }
-    // If there are no datasources/bundles with more than one view mode, don't
-    // display the description either.
-    if (!$options_present) {
-      unset($form['view_mode']['#type']);
-      unset($form['view_mode']['#description']);
-    }
-
-    return $form;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function alterPropertyDefinitions(array &$properties, DatasourceInterface $datasource = NULL) {
-    if ($datasource) {
-      return;
+  public function getPropertyDefinitions(DatasourceInterface $datasource = NULL) {
+    $properties = array();
+
+    if (!$datasource) {
+      $definition = array(
+        'type' => 'text',
+        'label' => $this->t('Rendered HTML output'),
+        'description' => $this->t('The complete HTML which would be displayed when viewing the item'),
+        'processor_id' => $this->getPluginId(),
+      );
+      $properties['rendered_item'] = new RenderedItemProperty($definition);
     }
-    $definition = array(
-      'type' => 'text',
-      'label' => $this->t('Rendered HTML output'),
-      'description' => $this->t('The complete HTML which would be displayed when viewing the item'),
-    );
-    $properties['rendered_item'] = new DataDefinition($definition);
-  }
 
-  /**
-   * {@inheritdoc}
-   */
-  public function preIndexSave() {
-    $this->ensureField(NULL, 'rendered_item');
+    return $properties;
   }
 
   /**
    * {@inheritdoc}
    */
-  public function preprocessIndexItems(array &$items) {
-    // Change the current user to our dummy implementation to ensure we are
-    // using the configured roles.
+  public function addFieldValues(ItemInterface $item) {
     $original_user = $this->currentUser->getAccount();
-    $this->currentUser->setAccount(new UserSession(array('roles' => $this->configuration['roles'])));
 
     // Count of items that don't have a view mode.
     $unset_view_modes = 0;
 
-    // Annoyingly, this doc comment is needed for PHPStorm. See
-    // http://youtrack.jetbrains.com/issue/WI-23586
-    /** @var \Drupal\search_api\Item\ItemInterface $item */
-    foreach ($items as $item) {
-      foreach ($this->filterForPropertyPath($item->getFields(), 'rendered_item') as $field) {
-        $datasource_id = $item->getDatasourceId();
-        $datasource = $item->getDatasource();
-        $bundle = $datasource->getItemBundle($item->getOriginalObject());
-        // When no view mode has been set for the bundle, or it has been set to
-        // "Don't include the rendered item", skip this item.
-        if (empty($this->configuration['view_mode'][$datasource_id][$bundle])) {
-          // If it was really not set, also notify the user through the log.
-          if (!isset($this->configuration['view_mode'][$datasource_id][$bundle])) {
-            ++$unset_view_modes;
-          }
-          continue;
-        }
-        else {
-          $view_mode = (string) $this->configuration['view_mode'][$datasource_id][$bundle];
+    foreach ($this->filterForPropertyPath($item->getFields(), 'rendered_item') as $field) {
+      $configuration = $field->getConfiguration();
+
+      // Change the current user to our dummy implementation to ensure we are
+      // using the configured roles.
+      $this->currentUser->setAccount(new UserSession(array('roles' => $configuration['roles'])));
+
+      $datasource_id = $item->getDatasourceId();
+      $datasource = $item->getDatasource();
+      $bundle = $datasource->getItemBundle($item->getOriginalObject());
+      // When no view mode has been set for the bundle, or it has been set to
+      // "Don't include the rendered item", skip this item.
+      if (empty($configuration['view_mode'][$datasource_id][$bundle])) {
+        // If it was really not set, also notify the user through the log.
+        if (!isset($configuration['view_mode'][$datasource_id][$bundle])) {
+          ++$unset_view_modes;
         }
+        continue;
+      }
+      else {
+        $view_mode = (string) $configuration['view_mode'][$datasource_id][$bundle];
+      }
 
-        $build = $datasource->viewItem($item->getOriginalObject(), $view_mode);
-        $value = (string) $this->getRenderer()->renderPlain($build);
-        if ($value) {
-          $field->addValue($value);
-        }
+      $build = $datasource->viewItem($item->getOriginalObject(), $view_mode);
+      $value = (string) $this->getRenderer()->renderPlain($build);
+      if ($value) {
+        $field->addValue($value);
       }
     }
 
+    // Restore the original user.
+    $this->currentUser->setAccount($original_user);
+
     if ($unset_view_modes > 0) {
       $context = array(
         '%index' => $this->index->label(),
         '%processor' => $this->label(),
         '@count' => $unset_view_modes,
       );
-      $this->getLogger()->warning('Warning: While indexing items on search index %index, @count item(s) did not have a view mode configured for the %processor processor.', $context);
+      $this->getLogger()->warning('Warning: While indexing items on search index %index, @count item(s) did not have a view mode configured for one or more "Rendered item" fields.', $context);
     }
-
-    // Restore the original user.
-    $this->currentUser->setAccount($original_user);
   }
 
   /**
@@ -293,15 +222,19 @@ public function preprocessIndexItems(array &$items) {
   public function calculateDependencies() {
     parent::calculateDependencies();
 
-    $view_modes = $this->configuration['view_mode'];
-    foreach ($this->index->getDatasources() as $datasource_id => $datasource) {
-      if (($entity_type_id = $datasource->getEntityTypeId()) && !empty($view_modes[$datasource_id])) {
-        foreach ($view_modes[$datasource_id] as $view_mode) {
-          if ($view_mode) {
-            /** @var \Drupal\Core\Entity\EntityViewModeInterface $view_mode_entity */
-            $view_mode_entity = EntityViewMode::load($entity_type_id . '.' . $view_mode);
-            if ($view_mode_entity) {
-              $this->addDependency($view_mode_entity->getConfigDependencyKey(), $view_mode_entity->getConfigDependencyName());
+    $fields = $this->index->getFieldsByDatasource(NULL);
+    $fields = $this->filterForPropertyPath($fields, 'rendered_item');
+    foreach ($fields as $field) {
+      $view_modes = $field->getConfiguration()['view_mode'];
+      foreach ($this->index->getDatasources() as $datasource_id => $datasource) {
+        if (($entity_type_id = $datasource->getEntityTypeId()) && !empty($view_modes[$datasource_id])) {
+          foreach ($view_modes[$datasource_id] as $view_mode) {
+            if ($view_mode) {
+              /** @var \Drupal\Core\Entity\EntityViewModeInterface $view_mode_entity */
+              $view_mode_entity = EntityViewMode::load($entity_type_id . '.' . $view_mode);
+              if ($view_mode_entity) {
+                $this->addDependency($view_mode_entity->getConfigDependencyKey(), $view_mode_entity->getConfigDependencyName());
+              }
             }
           }
         }
@@ -315,6 +248,7 @@ public function calculateDependencies() {
    * {@inheritdoc}
    */
   public function onDependencyRemoval(array $dependencies) {
+    # @todo
     // All dependencies of this processor are entity view modes, so we go
     // through our configuration and remove the settings for all datasources or
     // bundles which were set to one of the removed view modes. This will always
diff --git a/src/Processor/ConfigurablePropertyBase.php b/src/Processor/ConfigurablePropertyBase.php
new file mode 100644
index 0000000..8b901ba
--- /dev/null
+++ b/src/Processor/ConfigurablePropertyBase.php
@@ -0,0 +1,33 @@
+<?php
+
+namespace Drupal\search_api\Processor;
+
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\search_api\Item\FieldInterface;
+
+/**
+ * Provides a base class for configurable processor-defined properties.
+ */
+abstract class ConfigurablePropertyBase extends ProcessorProperty implements ConfigurablePropertyInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function defaultConfiguration() {
+    return array();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validateConfigurationForm(FieldInterface $field, array &$form, FormStateInterface $form_state) {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitConfigurationForm(FieldInterface $field, array &$form, FormStateInterface $form_state) {
+    $form_state->cleanValues();
+    $field->setConfiguration($form_state->getValues());
+  }
+
+}
diff --git a/src/Processor/ConfigurablePropertyInterface.php b/src/Processor/ConfigurablePropertyInterface.php
new file mode 100644
index 0000000..1205af0
--- /dev/null
+++ b/src/Processor/ConfigurablePropertyInterface.php
@@ -0,0 +1,62 @@
+<?php
+
+namespace Drupal\search_api\Processor;
+
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\search_api\Item\FieldInterface;
+
+/**
+ * Represents a processor-defined property with additional configuration.
+ */
+interface ConfigurablePropertyInterface extends ProcessorPropertyInterface {
+
+  /**
+   * Gets the default configuration for this property.
+   *
+   * @return array
+   *   An associative array with the default configuration.
+   */
+  public function defaultConfiguration();
+
+  /**
+   * Constructs a configuration form for a field based on this property.
+   *
+   * @param \Drupal\search_api\Item\FieldInterface $field
+   *   The field for which the configuration form is constructed.
+   * @param array $form
+   *   An associative array containing the initial structure of the form.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The current state of the complete form.
+   *
+   * @return array
+   *   The form structure.
+   */
+  public function buildConfigurationForm(FieldInterface $field, array $form, FormStateInterface $form_state);
+
+  /**
+   * Validates a configuration form for a field based on this property.
+   *
+   * @param \Drupal\search_api\Item\FieldInterface $field
+   *   The field for which the configuration form is validated.
+   * @param array $form
+   *   An associative array containing the structure of the plugin form as built
+   *   by static::buildConfigurationForm().
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The current state of the complete form.
+   */
+  public function validateConfigurationForm(FieldInterface $field, array &$form, FormStateInterface $form_state);
+
+  /**
+   * Submits a configuration form for a field based on this property.
+   *
+   * @param \Drupal\search_api\Item\FieldInterface $field
+   *   The field for which the configuration form is submitted.
+   * @param array $form
+   *   An associative array containing the structure of the plugin form as built
+   *   by static::buildConfigurationForm().
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The current state of the complete form.
+   */
+  public function submitConfigurationForm(FieldInterface $field, array &$form, FormStateInterface $form_state);
+
+}
diff --git a/src/Processor/ProcessorInterface.php b/src/Processor/ProcessorInterface.php
index 4c28e27..89d2029 100644
--- a/src/Processor/ProcessorInterface.php
+++ b/src/Processor/ProcessorInterface.php
@@ -4,6 +4,7 @@
 
 use Drupal\search_api\Datasource\DatasourceInterface;
 use Drupal\search_api\IndexInterface;
+use Drupal\search_api\Item\ItemInterface;
 use Drupal\search_api\Plugin\IndexPluginInterface;
 use Drupal\search_api\Query\QueryInterface;
 use Drupal\search_api\Query\ResultSetInterface;
@@ -25,6 +26,11 @@
 interface ProcessorInterface extends IndexPluginInterface {
 
   /**
+   * Processing stage: add properties.
+   */
+  const STAGE_ADD_PROPERTIES = 'add_properties';
+
+  /**
    * Processing stage: preprocess index.
    */
   const STAGE_PRE_INDEX_SAVE = 'pre_index_save';
@@ -108,15 +114,24 @@ public function isLocked();
   public function isHidden();
 
   /**
-   * Alters the given datasource's property definitions.
+   * Retrieves the properties this processor defines for the given datasource.
    *
-   * @param \Drupal\Core\TypedData\DataDefinitionInterface[] $properties
-   *   An array of property definitions for this datasource.
    * @param \Drupal\search_api\Datasource\DatasourceInterface|null $datasource
    *   (optional) The datasource this set of properties belongs to. If NULL, the
    *   datasource-independent properties should be added (or modified).
+   *
+   * @return \Drupal\search_api\Processor\ProcessorPropertyInterface[]
+   *   An array of property definitions for that datasource.
+   */
+  public function getPropertyDefinitions(DatasourceInterface $datasource = NULL);
+
+  /**
+   * Adds the values of properties defined by this processor to the item.
+   *
+   * @param \Drupal\search_api\Item\ItemInterface $item
+   *   The item whose field values should be added.
    */
-  public function alterPropertyDefinitions(array &$properties, DatasourceInterface $datasource = NULL);
+  public function addFieldValues(ItemInterface $item);
 
   /**
    * Preprocesses the search index entity before it is saved.
diff --git a/src/Processor/ProcessorPluginBase.php b/src/Processor/ProcessorPluginBase.php
index 0ee4393..9bb5c20 100644
--- a/src/Processor/ProcessorPluginBase.php
+++ b/src/Processor/ProcessorPluginBase.php
@@ -4,6 +4,7 @@
 
 use Drupal\search_api\Datasource\DatasourceInterface;
 use Drupal\search_api\IndexInterface;
+use Drupal\search_api\Item\ItemInterface;
 use Drupal\search_api\Plugin\IndexPluginBase;
 use Drupal\search_api\Query\QueryInterface;
 use Drupal\search_api\Query\ResultSetInterface;
@@ -83,7 +84,14 @@ public function isHidden() {
   /**
    * {@inheritdoc}
    */
-  public function alterPropertyDefinitions(array &$properties, DatasourceInterface $datasource = NULL) {}
+  public function getPropertyDefinitions(DatasourceInterface $datasource = NULL) {
+    return array();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function addFieldValues(ItemInterface $item) {}
 
   /**
    * {@inheritdoc}
@@ -122,13 +130,13 @@ protected function ensureField($datasource_id, $property_path, $type = NULL) {
         throw new SearchApiException("Could not find property '$property_id' which is required by the '$processor_label' processor.");
       }
       $field = Utility::createFieldFromProperty($this->index, $property, $datasource_id, $property_path, NULL, $type);
+      $this->index->addField($field);
     }
 
     $field->setIndexedLocked();
     if (isset($type)) {
       $field->setTypeLocked();
     }
-    $this->index->addField($field);
     return $field;
   }
 
diff --git a/src/Processor/ProcessorProperty.php b/src/Processor/ProcessorProperty.php
new file mode 100644
index 0000000..e847717
--- /dev/null
+++ b/src/Processor/ProcessorProperty.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace Drupal\search_api\Processor;
+
+use Drupal\Core\TypedData\DataDefinition;
+
+/**
+ * Provides a base class for normal processor-defined properties.
+ */
+class ProcessorProperty extends DataDefinition implements ProcessorPropertyInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getProcessorId() {
+    return $this->definition['processor_id'];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function isHidden() {
+    return !empty($this->definition['hidden']);
+  }
+
+}
diff --git a/src/Processor/ProcessorPropertyInterface.php b/src/Processor/ProcessorPropertyInterface.php
new file mode 100644
index 0000000..3b5148f
--- /dev/null
+++ b/src/Processor/ProcessorPropertyInterface.php
@@ -0,0 +1,28 @@
+<?php
+
+namespace Drupal\search_api\Processor;
+
+use Drupal\Core\TypedData\DataDefinitionInterface;
+
+/**
+ * Provides an interface for processor-defined properties.
+ */
+interface ProcessorPropertyInterface extends DataDefinitionInterface {
+
+  /**
+   * Retrieves the ID of the processor which defines this property.
+   *
+   * @return string
+   *   The defining processor's plugin ID.
+   */
+  public function getProcessorId();
+
+  /**
+   * Determines whether this property should be hidden from the UI.
+   *
+   * @return bool
+   *   TRUE if this property should not be displayed in the UI, FALSE otherwise.
+   */
+  public function isHidden();
+
+}
diff --git a/src/Tests/IntegrationTest.php b/src/Tests/IntegrationTest.php
index 3725fa2..b0fd39d 100644
--- a/src/Tests/IntegrationTest.php
+++ b/src/Tests/IntegrationTest.php
@@ -3,6 +3,10 @@
 namespace Drupal\search_api\Tests;
 
 use Drupal\Component\Utility\Html;
+use Drupal\Core\Entity\Entity\EntityFormDisplay;
+use Drupal\Core\Field\FieldStorageDefinitionInterface;
+use Drupal\field\Entity\FieldConfig;
+use Drupal\field\Entity\FieldStorageConfig;
 use Drupal\search_api\Entity\Server;
 use Drupal\search_api\SearchApiException;
 use Drupal\search_api\Utility;
@@ -557,14 +561,12 @@ public function enableAllProcessors() {
     $this->drupalGet($this->getIndexPath('processors'));
 
     $edit = array(
-      'status[aggregated_field]' => 1,
       'status[content_access]' => 1,
       'status[highlight]' => 1,
       'status[html_filter]' => 1,
       'status[ignorecase]' => 1,
       'status[ignore_character]' => 1,
       'status[node_status]' => 1,
-      'status[rendered_item]' => 1,
       'status[stopwords]' => 1,
       'status[tokenizer]' => 1,
       'status[transliteration]' => 1,
@@ -588,18 +590,19 @@ protected function checkFieldLabels() {
     $this->assertResponse(200);
     $this->drupalPostForm(NULL, $edit, $this->t('Save and manage fields'));
 
-    $field_name = '^6%{[*>.<"field';
-
     // Add a field to that content type with funky chars.
-    $edit = array(
-      'new_storage_type' => 'string',
+    $field_name = '^6%{[*>.<"field';
+    FieldStorageConfig::create(array(
+      'field_name' => 'field__field_',
+      'type' => 'string',
+      'entity_type' => 'node',
+    ))->save();
+    FieldConfig::create(array(
+      'field_name' => 'field__field_',
+      'entity_type' => 'node',
+      'bundle' => '_content_',
       'label' => $field_name,
-      'field_name' => '_field_',
-    );
-    $this->drupalGet('admin/structure/types/manage/_content_/fields/add-field');
-    $this->assertResponse(200);
-    $this->drupalPostForm(NULL, $edit, $this->t('Save and continue'));
-    $this->drupalPostForm(NULL, array(), $this->t('Save field settings'));
+    ))->save();
 
     $url_options['query']['datasource'] = 'entity:node';
     $this->drupalGet($this->getIndexPath('fields/add'), $url_options);
@@ -618,9 +621,10 @@ protected function checkFieldLabels() {
     $this->drupalPostForm(NULL, $edit, $this->t('Save'));
     $this->assertText($this->t('The index was successfully saved.'));
 
-    $this->drupalGet($this->getIndexPath('processors'));
-    $this->assertHtmlEscaped($content_type_name);
-    $this->assertHtmlEscaped($field_name);
+    # @todo add "rendered item" field and test there
+//    $this->drupalGet($this->getIndexPath('processors'));
+//    $this->assertHtmlEscaped($content_type_name);
+//    $this->assertHtmlEscaped($field_name);
   }
 
   /**
@@ -744,33 +748,37 @@ protected function addField($datasource_id, $property_path, $label = NULL) {
    * Tests field dependencies.
    */
   protected function addFieldsWithDependenciesToIndex() {
-    // Add a new field.
-    $edit = array(
-      'new_storage_type' => 'link',
+    // Add a new link field.
+    FieldStorageConfig::create(array(
+      'field_name' => 'field_link',
+      'type' => 'link',
+      'entity_type' => 'node',
+    ))->save();
+    FieldConfig::create(array(
+      'field_name' => 'field_link',
+      'entity_type' => 'node',
+      'bundle' => 'article',
       'label' => 'Link',
-      'field_name' => 'link',
-    );
-    $this->drupalPostForm('admin/structure/types/manage/article/fields/add-field', $edit, t('Save and continue'));
-    $this->drupalPostForm(NULL, array(), t('Save field settings'));
-    $this->drupalPostForm(NULL, array(), t('Save settings'));
-
-    // Add an image field.
-    $edit = array(
-      'new_storage_type' => 'image',
+    ))->save();
+
+    // Add a new image field, for both articles and basic pages.
+    FieldStorageConfig::create(array(
+      'field_name' => 'field_image',
+      'type' => 'image',
+      'entity_type' => 'node',
+    ))->save();
+    FieldConfig::create(array(
+      'field_name' => 'field_image',
+      'entity_type' => 'node',
+      'bundle' => 'article',
       'label' => 'Image',
-      'field_name' => 'image',
-    );
-    $this->drupalPostForm('admin/structure/types/manage/article/fields/add-field', $edit, t('Save and continue'));
-    $this->drupalPostForm(NULL, array(), t('Save field settings'));
-    $this->drupalPostForm(NULL, array(), t('Save settings'));
-
-    // Add the image field to the "Basic page" content type, too.
-    $edit = array(
-      'existing_storage_name' => 'field_image',
-      'existing_storage_label' => 'Image',
-    );
-    $this->drupalPostForm('admin/structure/types/manage/page/fields/add-field', $edit, t('Save and continue'));
-    $this->drupalPostForm(NULL, array(), t('Save settings'));
+    ))->save();
+    FieldConfig::create(array(
+      'field_name' => 'field_image',
+      'entity_type' => 'node',
+      'bundle' => 'page',
+      'label' => 'Image',
+    ))->save();
 
     $fields = array(
       'field_link' => $this->t('Link'),
@@ -838,15 +846,32 @@ protected function removeFieldsFromIndex() {
    * Tests if non-base fields of referenced entities can be added.
    */
   protected function checkReferenceFieldsNonBaseFields() {
-    // Add a entity_reference field.
+    // Add a new entity_reference field.
     $field_label = 'reference_field';
-    $edit = array(
-      'new_storage_type' => 'entity_reference',
+    FieldStorageConfig::create(array(
+      'field_name' => 'field__reference_field_',
+      'type' => 'entity_reference',
+      'entity_type' => 'node',
+      'cardinality' => FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED,
+      'settings' => array(
+        'allowed_values' => array(
+          array(
+            'target_type' => 'node',
+          ),
+        ),
+      ),
+    ))->save();
+    FieldConfig::create(array(
+      'field_name' => 'field__reference_field_',
+      'entity_type' => 'node',
+      'bundle' => 'article',
       'label' => $field_label,
-      'field_name' => '_reference_field_',
-    );
-    $this->drupalPostForm('admin/structure/types/manage/article/fields/add-field', $edit, $this->t('Save and continue'));
-    $this->drupalPostForm(NULL, array('cardinality' => -1), $this->t('Save field settings'));
+    ))->save();
+    EntityFormDisplay::load('node.article.default')
+      ->setComponent('field__reference_field_', array(
+        'type' => 'entity_reference_autocomplete',
+      ))
+      ->save();
 
     $node_label = $this->getIndex()->getDatasource('entity:node')->label();
     $field_label = "$field_label » $node_label » $field_label";
@@ -866,6 +891,7 @@ protected function configureFilter() {
     $edit = array(
       'status[ignorecase]' => 1,
       'processors[ignorecase][settings][fields][title]' => 'title',
+      'processors[ignorecase][settings][fields][field__field_]' => FALSE,
     );
     $this->drupalPostForm($this->getIndexPath('processors'), $edit, $this->t('Save'));
     $index = $this->getIndex(TRUE);
diff --git a/src/Tests/ProcessorIntegrationTest.php b/src/Tests/ProcessorIntegrationTest.php
index 57aed1c..25e2a5c 100644
--- a/src/Tests/ProcessorIntegrationTest.php
+++ b/src/Tests/ProcessorIntegrationTest.php
@@ -2,6 +2,7 @@
 
 namespace Drupal\search_api\Tests;
 
+use Drupal\Component\Utility\Html;
 use Drupal\search_api\Entity\Index;
 use Drupal\search_api\Entity\Server;
 use Drupal\search_api_test\PluginTestTrait;
@@ -45,13 +46,19 @@ public function setUp() {
    * avoid the overhead of having one test per processor.
    */
   public function testProcessorIntegration() {
-    // The add_url processor is always enabled.
-    $enabled = array('add_url');
-    sort($enabled);
+    // Some processors are always enabled.
+    $enabled = array('add_url', 'aggregated_field', 'rendered_item');
     $actual_processors = array_keys($this->loadIndex()->getProcessors());
     sort($actual_processors);
     $this->assertEqual($enabled, $actual_processors);
 
+    // Ensure the hidden processors aren't displayed in the UI.
+    $this->loadProcessorsTab();
+    $hidden = $enabled;
+    foreach ($hidden as $processor_id) {
+      $this->assertNoRaw(Html::escape($processor_id), "The \"$processor_id\" processor is not displayed in the UI.");
+    }
+
     $this->checkContentAccessIntegration();
     $enabled[] = 'content_access';
     sort($enabled);
@@ -95,11 +102,6 @@ public function testProcessorIntegration() {
     $this->assertEqual($enabled, $actual_processors);
 
     $this->checkRenderedItemIntegration();
-    $enabled[] = 'rendered_item';
-    sort($enabled);
-    $actual_processors = array_keys($this->loadIndex()->getProcessors());
-    sort($actual_processors);
-    $this->assertEqual($enabled, $actual_processors);
 
     $this->checkStopWordsIntegration();
     $enabled[] = 'stopwords';
@@ -270,19 +272,12 @@ public function checkNodeStatusIntegration() {
    * Tests the UI for the "Rendered item" processor.
    */
   public function checkRenderedItemIntegration() {
-    $configuration = $form_values = array(
-      'roles' => array(
-        'authenticated' => 'authenticated',
-      ),
-      'view_mode' => array(
-        'entity:node' => array(
-          'page' => 'default',
-          'article' => 'default',
-        ),
-      ),
-    );
-    $form_values['roles'] = array('authenticated');
-    $this->editSettingsForm($configuration, 'rendered_item', $form_values);
+    $index = $this->loadIndex();
+    $index->removeProcessor('rendered_item');
+    $index->save();
+
+    $processors = $this->loadIndex()->getProcessors();
+    $this->assertTrue(!empty($processors['rendered_item']), 'The "Rendered item" processor cannot be disabled.');
   }
 
   /**
@@ -322,8 +317,6 @@ public function checkTransliterationIntegration() {
    */
   public function checkUrlFieldIntegration() {
     $index = $this->loadIndex();
-    $processors = $index->getProcessors();
-    $this->assertTrue(!empty($processors['add_url']), 'The "Add URL" processor is enabled by default.');
     $index->removeProcessor('add_url');
     $index->save();
 
diff --git a/src/UnsavedIndexConfiguration.php b/src/UnsavedIndexConfiguration.php
index 7b21c9e..c186196 100644
--- a/src/UnsavedIndexConfiguration.php
+++ b/src/UnsavedIndexConfiguration.php
@@ -350,8 +350,8 @@ public function getFulltextFields() {
   /**
    * {@inheritdoc}
    */
-  public function getPropertyDefinitions($datasource_id, $alter = TRUE) {
-    return $this->entity->getPropertyDefinitions($datasource_id, $alter);
+  public function getPropertyDefinitions($datasource_id) {
+    return $this->entity->getPropertyDefinitions($datasource_id);
   }
 
   /**
diff --git a/src/Utility.php b/src/Utility.php
index 9bb55fe..1585562 100644
--- a/src/Utility.php
+++ b/src/Utility.php
@@ -18,6 +18,7 @@
 use Drupal\search_api\Item\Field;
 use Drupal\search_api\Item\FieldInterface;
 use Drupal\search_api\Item\Item;
+use Drupal\search_api\Processor\ConfigurablePropertyInterface;
 use Drupal\search_api\Query\Query;
 use Drupal\search_api\Query\QueryInterface;
 use Drupal\search_api\Query\ResultSet;
@@ -660,6 +661,9 @@ public static function createFieldFromProperty(IndexInterface $index, DataDefini
       'property_path' => $property_path,
       'type' => $type,
     );
+    if ($property instanceof ConfigurablePropertyInterface) {
+      $field_info['configuration'] = $property->defaultConfiguration();
+    }
     return self::createField($index, $field_id, $field_info);
   }
 
diff --git a/tests/search_api_test_db/config/install/search_api.index.database_search_index.yml b/tests/search_api_test_db/config/install/search_api.index.database_search_index.yml
index 6d82f63..eaa60d6 100644
--- a/tests/search_api_test_db/config/install/search_api.index.database_search_index.yml
+++ b/tests/search_api_test_db/config/install/search_api.index.database_search_index.yml
@@ -50,11 +50,17 @@ processor_settings:
     settings:
       weights:
         preprocess_index: -30
-  language:
-    plugin_id: language
+  aggregated_field:
+    plugin_id: aggregated_field
     settings:
       weights:
-        preprocess_index: -50
+        add_properties: 20
+  rendered_item:
+    plugin_id: rendered_item
+    settings:
+      weights:
+        add_properties: 0
+        pre_index_save: -10
 options:
   cron_limit: -1
   index_directly: false
diff --git a/tests/search_api_test_hooks/search_api_test_hooks.module b/tests/search_api_test_hooks/search_api_test_hooks.module
index 3e93f40..0f7656b 100644
--- a/tests/search_api_test_hooks/search_api_test_hooks.module
+++ b/tests/search_api_test_hooks/search_api_test_hooks.module
@@ -23,7 +23,7 @@ function search_api_test_hooks_search_api_datasource_info_alter(array &$infos) {
  * Implements hook_search_api_processor_info_alter.
  */
 function search_api_test_hooks_search_api_processor_info_alter(array &$processors) {
-  $processors['rendered_item']['label'] = 'Mystic bounce';
+  $processors['content_access']['label'] = 'Mystic bounce';
 }
 
 /**
diff --git a/tests/src/Kernel/CustomDataTypesTest.php b/tests/src/Kernel/CustomDataTypesTest.php
index 66d5563..31360e1 100644
--- a/tests/src/Kernel/CustomDataTypesTest.php
+++ b/tests/src/Kernel/CustomDataTypesTest.php
@@ -106,8 +106,14 @@ public function setUp() {
     ));
     $this->server->save();
 
+    // Set the server (determines the supported data types) and remove all
+    // non-base fields from the index (since their config isn't installed).
     $this->index = Index::load('database_search_index');
-    $this->index->setServer($this->server);
+    $this->index->setServer($this->server)
+      ->removeField('body')
+      ->removeField('keywords')
+      ->removeField('category')
+      ->removeField('width');
   }
 
   /**
diff --git a/tests/src/Kernel/Processor/ContentAccessTest.php b/tests/src/Kernel/Processor/ContentAccessTest.php
index ab230f7..1964845 100644
--- a/tests/src/Kernel/Processor/ContentAccessTest.php
+++ b/tests/src/Kernel/Processor/ContentAccessTest.php
@@ -6,6 +6,7 @@
 use Drupal\comment\Entity\CommentType;
 use Drupal\comment\Tests\CommentTestTrait;
 use Drupal\Core\Database\Database;
+use Drupal\Core\TypedData\DataDefinitionInterface;
 use Drupal\node\Entity\Node;
 use Drupal\node\Entity\NodeType;
 use Drupal\search_api\Query\ResultSetInterface;
@@ -239,7 +240,10 @@ public function testContentAccessAll() {
     }
     $items = $this->generateItems($items);
 
-    $this->processor->preprocessIndexItems($items);
+    // Add the processor's field values to the items.
+    foreach ($items as $item) {
+      $this->processor->addFieldValues($item);
+    }
 
     foreach ($items as $item) {
       $this->assertEquals(array('node_access__all'), $item->getField('search_api_node_grants')->getValues());
@@ -261,7 +265,10 @@ public function testContentAccessWithNodeGrants() {
     }
     $items = $this->generateItems($items);
 
-    $this->processor->preprocessIndexItems($items);
+    // Add the processor's field values to the items.
+    foreach ($items as $item) {
+      $this->processor->addFieldValues($item);
+    }
 
     foreach ($items as $item) {
       $this->assertEquals(array('node_access_search_api_test:0'), $item->getField('search_api_node_grants')->getValues());
@@ -292,6 +299,21 @@ public function testNodeGrantsChange() {
   }
 
   /**
+   * Tests whether the property is correctly added by the processor.
+   */
+  public function testAlterPropertyDefinitions() {
+    // Check for added properties when no datasource is given.
+    $properties = $this->processor->getPropertyDefinitions(NULL);
+    $this->assertTrue(array_key_exists('search_api_node_grants', $properties), 'The Properties where modified with the "search_api_node_grants".');
+    $this->assertTrue(($properties['search_api_node_grants'] instanceof DataDefinitionInterface), 'The "search_api_node_grants" key contains a valid DataDefinition instance.');
+    $this->assertEquals('string', $properties['search_api_node_grants']->getDataType(), 'Correct DataType set in the DataDefinition.');
+
+    // Verify that there are no properties if a datasource is given.
+    $properties = $this->processor->getPropertyDefinitions($this->index->getDatasource('entity:node'));
+    $this->assertEquals(array(), $properties, '"search_api_node_grants" property not added when data source is given.');
+  }
+
+  /**
    * Asserts that the search results contain the expected IDs.
    *
    * @param \Drupal\search_api\Query\ResultSetInterface $result
@@ -312,7 +334,9 @@ protected function assertResults(ResultSetInterface $result, array $expected) {
           $id = $i . ':en';
         }
         else {
-          $id = $this->{"{$entity_type}s"}[$i]->id() . ':en';
+          /** @var \Drupal\Core\Entity\EntityInterface $entity */
+          $entity = $this->{"{$entity_type}s"}[$i];
+          $id = $entity->id() . ':en';
         }
         $ids[] = Utility::createCombinedId($datasource_id, $id);
       }
diff --git a/tests/src/Kernel/Processor/RenderedItemTest.php b/tests/src/Kernel/Processor/RenderedItemTest.php
index 91ba984..a39d27d 100644
--- a/tests/src/Kernel/Processor/RenderedItemTest.php
+++ b/tests/src/Kernel/Processor/RenderedItemTest.php
@@ -2,7 +2,7 @@
 
 namespace Drupal\Tests\search_api\Kernel\Processor;
 
-use Drupal\Core\TypedData\DataDefinition;
+use Drupal\Core\TypedData\DataDefinitionInterface;
 use Drupal\node\Entity\Node;
 use Drupal\node\Entity\NodeType;
 use Drupal\search_api\Utility;
@@ -94,22 +94,28 @@ public function setUp($processor = NULL) {
     $this->nodes[2] = Node::create($node_data);
     $this->nodes[2]->save();
 
-    // Set proper configuration for the tested processor.
-    $config = $this->processor->getConfiguration();
-    $config['view_mode'] = array(
-      'entity:node' => array(
-        'page' => 'full',
-        'article' => 'teaser',
-      ),
-      'entity:user' => array(
-        'user' => 'compact',
-      ),
-      'entity:comment' => array(
-        'comment' => 'teaser',
+    // Add a field based on the "rendered_item" property.
+    $field_info = array(
+      'type' => 'text',
+      'property_path' => 'rendered_item',
+      'configuration' => array(
+        'roles' => array($role->id()),
+        'view_mode' => array(
+          'entity:node' => array(
+            'page' => 'full',
+            'article' => 'teaser',
+          ),
+          'entity:user' => array(
+            'user' => 'compact',
+          ),
+          'entity:comment' => array(
+            'comment' => 'teaser',
+          ),
+        ),
       ),
     );
-    $config['roles'] = array($role->id());
-    $this->processor->setConfiguration($config);
+    $field = Utility::createField($this->index, 'rendered_item', $field_info);
+    $this->index->addField($field);
 
     $this->index->save();
 
@@ -121,15 +127,9 @@ public function setUp($processor = NULL) {
   }
 
   /**
-   * Tests that the processor is added correctly.
+   * Tests whether the rendered_item field is correctly filled by the processor.
    */
-  public function testAddProcessor() {
-    $processors = $this->index->getProcessors();
-    $this->assertTrue(
-      array_key_exists('rendered_item', $processors),
-      'Processor successfully added.'
-    );
-
+  public function testAddFieldValues() {
     $items = array();
     foreach ($this->nodes as $node) {
       $items[] = array(
@@ -141,30 +141,11 @@ public function testAddProcessor() {
     }
     $items = $this->generateItems($items);
 
+    // Add the processor's field values to the items.
     foreach ($items as $item) {
-      $this->assertTrue(
-        array_key_exists('rendered_item', $item->getFields()),
-        'Field successfully added.'
-      );
+      $this->processor->addFieldValues($item);
     }
-  }
 
-  /**
-   * Tests whether the rendered_item field is correctly filled by the processor.
-   */
-  public function testPreprocessIndexItems() {
-    $items = array();
-    foreach ($this->nodes as $node) {
-      $items[] = array(
-        'datasource' => 'entity:node',
-        'item' => $node->getTypedData(),
-        'item_id' => $node->id(),
-        'text' => 'node text' . $node->id(),
-      );
-    }
-    $items = $this->generateItems($items);
-
-    $this->processor->preprocessIndexItems($items);
     foreach ($items as $key => $item) {
       list(, $nid) = Utility::splitCombinedId($key);
       $field = $item->getField('rendered_item');
@@ -192,14 +173,15 @@ public function testPreprocessIndexItems() {
   public function testHideRenderedItem() {
     // Change the processor configuration to make sure that that the rendered
     // item content will be empty.
-    $config = $this->processor->getConfiguration();
+    $field = $this->index->getField('rendered_item');
+    $config = $field->getConfiguration();
     $config['view_mode'] = array(
-      'entity:node' => [
+      'entity:node' => array(
         'page' => '',
         'article' => '',
-      ],
+      ),
     );
-    $this->processor->setConfiguration($config);
+    $field->setConfiguration($config);
 
     // Create items that we can index.
     $items = array();
@@ -213,8 +195,10 @@ public function testHideRenderedItem() {
     }
     $items = $this->generateItems($items);
 
-    // Preprocess the items for indexing.
-    $this->processor->preprocessIndexItems($items);
+    // Add the processor's field values to the items.
+    foreach ($items as $item) {
+      $this->processor->addFieldValues($item);
+    }
 
     // Verify that no field values were added.
     foreach ($items as $key => $item) {
@@ -227,17 +211,15 @@ public function testHideRenderedItem() {
    * Tests whether the property is correctly added by the processor.
    */
   public function testAlterPropertyDefinitions() {
-    // Check for modified properties when no datasource is given.
-    /** @var \Drupal\Core\TypedData\DataDefinitionInterface[] $properties */
-    $properties = array();
-    $this->processor->alterPropertyDefinitions($properties, NULL);
+    // Check for added properties when no datasource is given.
+    $properties = $this->processor->getPropertyDefinitions(NULL);
     $this->assertTrue(array_key_exists('rendered_item', $properties), 'The Properties where modified with the "rendered_item".');
-    $this->assertTrue(($properties['rendered_item'] instanceof DataDefinition), 'The "rendered_item" contains a valid DataDefinition instance.');
+    $this->assertInstanceOf('Drupal\search_api\Plugin\search_api\processor\Property\RenderedItemProperty', $properties['rendered_item'], 'Added property has the correct class.');
+    $this->assertTrue(($properties['rendered_item'] instanceof DataDefinitionInterface), 'The "rendered_item" contains a valid DataDefinition instance.');
     $this->assertEquals('text', $properties['rendered_item']->getDataType(), 'Correct DataType set in the DataDefinition.');
 
-    // Check if the properties stay untouched if a datasource is given.
-    $properties = array();
-    $this->processor->alterPropertyDefinitions($properties, $this->index->getDatasource('entity:node'));
+    // Verify that there are no properties if a datasource is given.
+    $properties = $this->processor->getPropertyDefinitions($this->index->getDatasource('entity:node'));
     $this->assertEquals(array(), $properties, '"render_item" property not added when data source is given.');
   }
 
diff --git a/tests/src/Unit/Plugin/Processor/AddURLTest.php b/tests/src/Unit/Plugin/Processor/AddURLTest.php
index 9f93e46..9edab74 100644
--- a/tests/src/Unit/Plugin/Processor/AddURLTest.php
+++ b/tests/src/Unit/Plugin/Processor/AddURLTest.php
@@ -70,9 +70,9 @@ protected function setUp() {
   }
 
   /**
-   * Tests whether indexed items are correctly preprocessed.
+   * Tests whether the "URI" field is correctly filled by the processor.
    */
-  public function testProcessIndexItems() {
+  public function testAddFieldValues() {
     /** @var \Drupal\node\Entity\Node $node */
     $node = $this->getMockBuilder('Drupal\node\Entity\Node')
       ->disableOriginalConstructor()
@@ -90,8 +90,10 @@ public function testProcessIndexItems() {
     );
     $items = $this->createItems($this->index, 2, $fields, EntityAdapter::createFromEntity($node));
 
-    // Process the items.
-    $this->processor->preprocessIndexItems($items);
+    // Add the processor's field values to the items.
+    foreach ($items as $item) {
+      $this->processor->addFieldValues($item);
+    }
 
     // Check the valid item.
     $field = $items[$this->itemIds[0]]->getField('search_api_url');
@@ -112,10 +114,8 @@ public function testProcessIndexItems() {
    * @see \Drupal\search_api\Plugin\search_api\processor\AddURL::alterPropertyDefinitions()
    */
   public function testAlterPropertyDefinitions() {
-    $properties = array();
-
-    // Check for modified properties when no data source is given.
-    $this->processor->alterPropertyDefinitions($properties, NULL);
+    // Check for added properties when no datasource is given.
+    $properties = $this->processor->getPropertyDefinitions(NULL);
     $property_added = array_key_exists('search_api_url', $properties);
     $this->assertTrue($property_added, 'The "search_api_url" property was added to the properties.');
     if ($property_added) {
@@ -127,11 +127,9 @@ public function testAlterPropertyDefinitions() {
       }
     }
 
-    // Test whether the properties of specific datasources stay untouched.
-    $properties = array();
-    /** @var \Drupal\search_api\Datasource\DatasourceInterface $datasource */
+    // Verify that there are no properties if a datasource is given.
     $datasource = $this->getMock('Drupal\search_api\Datasource\DatasourceInterface');
-    $this->processor->alterPropertyDefinitions($properties, $datasource);
+    $properties = $this->processor->getPropertyDefinitions($datasource);
     $this->assertEmpty($properties, 'Datasource-specific properties did not get changed.');
   }
 
diff --git a/tests/src/Unit/Plugin/Processor/AggregatedFieldTest.php b/tests/src/Unit/Plugin/Processor/AggregatedFieldTest.php
deleted file mode 100644
index 194eb76..0000000
--- a/tests/src/Unit/Plugin/Processor/AggregatedFieldTest.php
+++ /dev/null
@@ -1,514 +0,0 @@
-<?php
-
-namespace Drupal\Tests\search_api\Unit\Plugin\Processor;
-
-use Drupal\Core\TypedData\DataDefinitionInterface;
-use Drupal\search_api\Plugin\search_api\processor\AggregatedFields;
-use Drupal\search_api\Utility;
-use Drupal\Tests\UnitTestCase;
-
-/**
- * Tests the "Aggregated fields" processor.
- *
- * @group search_api
- *
- * @see \Drupal\search_api\Plugin\search_api\processor\AggregatedField
- */
-class AggregatedFieldTest extends UnitTestCase {
-
-  use TestItemsTrait;
-
-  /**
-   * The processor to be tested.
-   *
-   * @var \Drupal\search_api\Plugin\search_api\processor\AggregatedFields
-   */
-  protected $processor;
-
-  /**
-   * A search index mock for the tests.
-   *
-   * @var \Drupal\search_api\IndexInterface|\PHPUnit_Framework_MockObject_MockObject
-   */
-  protected $index;
-
-  /**
-   * Creates a new processor object for use in the tests.
-   */
-  protected function setUp() {
-    parent::setUp();
-
-    $this->index = $this->getMock('Drupal\search_api\IndexInterface');
-    $this->index->method('getDatasourceIds')
-      ->will($this->returnValue(array('entity:test1', 'entity:test2')));
-    $this->processor = new AggregatedFields(array('index' => $this->index), 'aggregated_field', array());
-    $this->setUpDataTypePlugin();
-  }
-
-  /**
-   * Tests creation of an aggregated field of type "union".
-   */
-  public function testUnionAggregation() {
-    $field_id = 'search_api_aggregation_1';
-    $configuration = array(
-      'fields' => array(
-        $field_id => array(
-          'label' => 'Test field',
-          'type' => 'union',
-          'fields' => array(
-            'entity:test1/foo',
-            'entity:test1/foo:bar',
-            'entity:test2/foobaz:bla',
-          ),
-        ),
-      ),
-    );
-    $this->processor->setConfiguration($configuration);
-
-    $fields = array(
-      'entity:test1/foo' => array(
-        'type' => 'string',
-        'values' => array('foo', 'bar'),
-      ),
-      'entity:test1/foo:bar' => array(
-        'type' => 'string',
-        'values' => array('baz'),
-      ),
-      'entity:test2/foobaz:bla' => array(
-        'type' => 'string',
-        'values' => array('foobar'),
-      ),
-      $field_id => array(
-        'type' => 'string',
-      ),
-    );
-    $items = $this->createItems($this->index, 2, $fields, NULL, array('entity:test1', 'entity:test2'));
-
-    $this->processor->preprocessIndexItems($items);
-
-    $expected = array('foo', 'bar', 'baz');
-    $this->assertEquals($expected, $items[$this->itemIds[0]]->getField($field_id)->getValues(), 'Correct "union" aggregation for item 1.');
-
-    $expected = array('foobar');
-    $this->assertEquals($expected, $items[$this->itemIds[1]]->getField($field_id)->getValues(), 'Correct "union" aggregation for item 1.');
-  }
-
-  /**
-   * Tests creation of an aggregated field of type "concat".
-   */
-  public function testConcatAggregation() {
-    $field_id = 'search_api_aggregation_1';
-    $configuration = array(
-      'fields' => array(
-        $field_id => array(
-          'label' => 'Test field',
-          'type' => 'concat',
-          'fields' => array(
-            'entity:test1/foo',
-            'entity:test1/foo:bar',
-            'entity:test2/foobaz:bla',
-          ),
-        ),
-      ),
-    );
-    $this->processor->setConfiguration($configuration);
-
-    $fields = array(
-      'entity:test1/foo' => array(
-        'type' => 'string',
-        'values' => array('foo', 'bar'),
-      ),
-      'entity:test1/foo:bar' => array(
-        'type' => 'string',
-        'values' => array('baz'),
-      ),
-      'entity:test2/foobaz:bla' => array(
-        'type' => 'string',
-        'values' => array('foobar'),
-      ),
-      $field_id => array(
-        'type' => 'string',
-      ),
-    );
-    $items = $this->createItems($this->index, 2, $fields, NULL, array('entity:test1', 'entity:test2'));
-
-    $this->processor->preprocessIndexItems($items);
-
-    $expected = array("foo\n\nbar\n\nbaz");
-    $this->assertEquals($expected, $items[$this->itemIds[0]]->getField($field_id)->getValues(), 'Correct "concat" aggregation for item 1.');
-
-    $expected = array('foobar');
-    $this->assertEquals($expected, $items[$this->itemIds[1]]->getField($field_id)->getValues(), 'Correct "concat" aggregation for item 1.');
-  }
-
-  /**
-   * Tests creation of an aggregated field of type "sum".
-   */
-  public function testSumAggregation() {
-    $field_id = 'search_api_aggregation_1';
-    $configuration = array(
-      'fields' => array(
-        $field_id => array(
-          'label' => 'Test field',
-          'type' => 'sum',
-          'fields' => array(
-            'entity:test1/foo',
-            'entity:test1/foo:bar',
-            'entity:test2/foobaz:bla',
-          ),
-        ),
-      ),
-    );
-    $this->processor->setConfiguration($configuration);
-
-    $fields = array(
-      'entity:test1/foo' => array(
-        'type' => 'integer',
-        'values' => array(2, 4),
-      ),
-      'entity:test1/foo:bar' => array(
-        'type' => 'integer',
-        'values' => array(16),
-      ),
-      'entity:test2/foobaz:bla' => array(
-        'type' => 'integer',
-        'values' => array(7),
-      ),
-      $field_id => array(
-        'type' => 'integer',
-      ),
-    );
-    $items = $this->createItems($this->index, 2, $fields, NULL, array('entity:test1', 'entity:test2'));
-
-    $this->processor->preprocessIndexItems($items);
-
-    $expected = array(22);
-    $this->assertEquals($expected, $items[$this->itemIds[0]]->getField($field_id)->getValues(), 'Correct "sum" aggregation for item 1.');
-
-    $expected = array(7);
-    $this->assertEquals($expected, $items[$this->itemIds[1]]->getField($field_id)->getValues(), 'Correct "sum" aggregation for item 1.');
-  }
-
-  /**
-   * Tests creation of an aggregated field of type "count".
-   */
-  public function testCountAggregation() {
-    $field_id = 'search_api_aggregation_1';
-    $configuration = array(
-      'fields' => array(
-        $field_id => array(
-          'label' => 'Test field',
-          'type' => 'count',
-          'fields' => array(
-            'entity:test1/foo',
-            'entity:test1/foo:bar',
-            'entity:test2/foobaz:bla',
-          ),
-        ),
-      ),
-    );
-    $this->processor->setConfiguration($configuration);
-
-    $fields = array(
-      'entity:test1/foo' => array(
-        'type' => 'string',
-        'values' => array('foo', 'bar'),
-      ),
-      'entity:test1/foo:bar' => array(
-        'type' => 'string',
-        'values' => array('baz'),
-      ),
-      'entity:test2/foobaz:bla' => array(
-        'type' => 'string',
-        'values' => array('foobar'),
-      ),
-      $field_id => array(
-        'type' => 'string',
-      ),
-    );
-    $items = $this->createItems($this->index, 2, $fields, NULL, array('entity:test1', 'entity:test2'));
-
-    $this->processor->preprocessIndexItems($items);
-
-    $expected = array(3);
-    $this->assertEquals($expected, $items[$this->itemIds[0]]->getField($field_id)->getValues(), 'Correct "count" aggregation for item 1.');
-
-    $expected = array(1);
-    $this->assertEquals($expected, $items[$this->itemIds[1]]->getField($field_id)->getValues(), 'Correct "count" aggregation for item 1.');
-  }
-
-  /**
-   * Tests creation of an aggregated field of type "max".
-   */
-  public function testMaxAggregation() {
-    $field_id = 'search_api_aggregation_1';
-    $configuration = array(
-      'fields' => array(
-        $field_id => array(
-          'label' => 'Test field',
-          'type' => 'max',
-          'fields' => array(
-            'entity:test1/foo',
-            'entity:test1/foo:bar',
-            'entity:test2/foobaz:bla',
-          ),
-        ),
-      ),
-    );
-    $this->processor->setConfiguration($configuration);
-
-    $fields = array(
-      'entity:test1/foo' => array(
-        'type' => 'integer',
-        'values' => array(2, 4),
-      ),
-      'entity:test1/foo:bar' => array(
-        'type' => 'integer',
-        'values' => array(16),
-      ),
-      'entity:test2/foobaz:bla' => array(
-        'type' => 'integer',
-        'values' => array(7),
-      ),
-      $field_id => array(
-        'type' => 'integer',
-      ),
-    );
-    $items = $this->createItems($this->index, 2, $fields, NULL, array('entity:test1', 'entity:test2'));
-
-    $this->processor->preprocessIndexItems($items);
-
-    $expected = array(16);
-    $this->assertEquals($expected, $items[$this->itemIds[0]]->getField($field_id)->getValues(), 'Correct "max" aggregation for item 1.');
-
-    $expected = array(7);
-    $this->assertEquals($expected, $items[$this->itemIds[1]]->getField($field_id)->getValues(), 'Correct "max" aggregation for item 1.');
-  }
-
-  /**
-   * Tests creation of an aggregated field of type "min".
-   */
-  public function testMinAggregation() {
-    $field_id = 'search_api_aggregation_1';
-    $configuration = array(
-      'fields' => array(
-        $field_id => array(
-          'label' => 'Test field',
-          'type' => 'min',
-          'fields' => array(
-            'entity:test1/foo',
-            'entity:test1/foo:bar',
-            'entity:test2/foobaz:bla',
-          ),
-        ),
-      ),
-    );
-    $this->processor->setConfiguration($configuration);
-
-    $fields = array(
-      'entity:test1/foo' => array(
-        'type' => 'integer',
-        'values' => array(2, 4),
-      ),
-      'entity:test1/foo:bar' => array(
-        'type' => 'integer',
-        'values' => array(16),
-      ),
-      'entity:test2/foobaz:bla' => array(
-        'type' => 'integer',
-        'values' => array(7),
-      ),
-      $field_id => array(
-        'type' => 'integer',
-      ),
-    );
-    $items = $this->createItems($this->index, 2, $fields, NULL, array('entity:test1', 'entity:test2'));
-
-    $this->processor->preprocessIndexItems($items);
-
-    $expected = array(2);
-    $this->assertEquals($expected, $items[$this->itemIds[0]]->getField($field_id)->getValues(), 'Correct "min" aggregation for item 1.');
-
-    $expected = array(7);
-    $this->assertEquals($expected, $items[$this->itemIds[1]]->getField($field_id)->getValues(), 'Correct "min" aggregation for item 1.');
-  }
-
-  /**
-   * Tests creation of an aggregated field of type "first".
-   */
-  public function testFirstAggregation() {
-    $field_id = 'search_api_aggregation_1';
-    $configuration = array(
-      'fields' => array(
-        $field_id => array(
-          'label' => 'Test field',
-          'type' => 'first',
-          'fields' => array(
-            'entity:test1/foo',
-            'entity:test1/foo:bar',
-            'entity:test2/foobaz:bla',
-          ),
-        ),
-      ),
-    );
-    $this->processor->setConfiguration($configuration);
-
-    $fields = array(
-      'entity:test1/foo' => array(
-        'type' => 'string',
-        'values' => array('foo', 'bar'),
-      ),
-      'entity:test1/foo:bar' => array(
-        'type' => 'string',
-        'values' => array('baz'),
-      ),
-      'entity:test2/foobaz:bla' => array(
-        'type' => 'string',
-        'values' => array('foobar'),
-      ),
-      $field_id => array(
-        'type' => 'string',
-      ),
-    );
-    $items = $this->createItems($this->index, 2, $fields, NULL, array('entity:test1', 'entity:test2'));
-
-    $this->processor->preprocessIndexItems($items);
-
-    $expected = array('foo');
-    $this->assertEquals($expected, $items[$this->itemIds[0]]->getField($field_id)->getValues(), 'Correct "first" aggregation for item 1.');
-
-    $expected = array('foobar');
-    $this->assertEquals($expected, $items[$this->itemIds[1]]->getField($field_id)->getValues(), 'Correct "first" aggregation for item 1.');
-  }
-
-  /**
-   * Tests whether unindexed aggregated fields are correctly skipped.
-   */
-  public function testUnindexedAggregatedField() {
-    $configuration = array(
-      'fields' => array(
-        'search_api_aggregation_1' => array(
-          'label' => 'Test field',
-          'type' => 'union',
-          'fields' => array(
-            'entity:test1/foo',
-            'entity:test1/foo:bar',
-          ),
-        ),
-      ),
-    );
-    $this->processor->setConfiguration($configuration);
-
-    $fields = array(
-      'entity:test1/foo' => array(
-        'type' => 'string',
-        'values' => array('foo', 'bar'),
-      ),
-      'entity:test1/foo:bar' => array(
-        'type' => 'string',
-        'values' => array('baz'),
-      ),
-    );
-    $items = $this->createItems($this->index, 2, $fields, NULL, array('entity:test1', 'entity:test2'));
-
-    $this->processor->preprocessIndexItems($items);
-
-    $this->assertEquals(NULL, $items[$this->itemIds[0]]->getField('search_api_aggregation_1'), 'Unindexed aggregated field was not added for item 1.');
-    $this->assertEquals(NULL, $items[$this->itemIds[1]]->getField('search_api_aggregation_1'), 'Unindexed aggregated field was not added for item 2.');
-  }
-
-  /**
-   * Tests whether the properties are correctly altered.
-   *
-   * @see \Drupal\search_api\Plugin\search_api\processor\AggregatedField::alterPropertyDefinitions()
-   */
-  public function testAlterPropertyDefinitions() {
-    $fields = array(
-      'entity:test1/foo',
-      'entity:test1/foo:bar',
-      'entity:test2/foobaz:bla',
-    );
-    $index_fields = array();
-    foreach ($fields as $field_id) {
-      $field_object = Utility::createField($this->index, $field_id);
-      list($prefix, $label) = str_replace(':', ' » ', Utility::splitCombinedId($field_id));
-      $field_object->setLabelPrefix($prefix . ' » ');
-      $field_object->setLabel($label);
-      $index_fields[$field_id] = $field_object;
-    }
-    $this->index->expects($this->any())
-      ->method('getFields')
-      ->willReturn($index_fields);
-    $this->index->method('getPropertyDefinitions')
-      ->willReturn(array());
-    $dummy_datasource = $this->getMock('Drupal\search_api\Datasource\DatasourceInterface');
-    $dummy_datasource->method('label')
-      ->willReturn('datasource');
-    $this->index->method('getDatasources')
-      ->willReturn(array(
-        'entity:test1' => $dummy_datasource,
-        'entity:test2' => $dummy_datasource,
-      ));
-
-    $configuration['fields'] = array(
-      'search_api_aggregation_1' => array(
-        'label' => 'Field 1',
-        'type' => 'union',
-        'fields' => array(
-          'entity:test1/foo',
-          'entity:test1/foo:bar',
-          'entity:test2/foobaz:bla',
-        ),
-      ),
-      'search_api_aggregation_2' => array(
-        'label' => 'Field 2',
-        'type' => 'max',
-        'fields' => array(
-          'entity:test1/foo:bar',
-        ),
-      ),
-    );
-    $this->processor->setConfiguration($configuration);
-
-    /** @var \Drupal\Core\StringTranslation\TranslationInterface $translation */
-    $translation = $this->getStringTranslationStub();
-    $this->processor->setStringTranslation($translation);
-
-    // Check for modified properties when no datasource is given.
-    $properties = array();
-    $this->processor->alterPropertyDefinitions($properties, NULL);
-
-    $property_added = array_key_exists('search_api_aggregation_1', $properties);
-    $this->assertTrue($property_added, 'The "search_api_aggregation_1" property was added to the properties.');
-    if ($property_added) {
-      $this->assertInstanceOf('Drupal\Core\TypedData\DataDefinitionInterface', $properties['search_api_aggregation_1'], 'The "search_api_aggregation_1" property contains a valid data definition.');
-      if ($properties['search_api_aggregation_1'] instanceof DataDefinitionInterface) {
-        $this->assertEquals('string', $properties['search_api_aggregation_1']->getDataType(), 'Correct data type set in the data definition.');
-        $this->assertEquals('Field 1', $properties['search_api_aggregation_1']->getLabel(), 'Correct label set in the data definition.');
-        $fields_string = 'datasource » foo, datasource » foo:bar, datasource » foobaz:bla';
-        $description = $translation->translate('A @type aggregation of the following fields: @fields.', array('@type' => $translation->translate('Union'), '@fields' => $fields_string));;
-        $this->assertEquals($description, $properties['search_api_aggregation_1']->getDescription(), 'Correct description set in the data definition.');
-      }
-    }
-
-    $property_added = array_key_exists('search_api_aggregation_2', $properties);
-    $this->assertTrue($property_added, 'The "search_api_aggregation_2" property was added to the properties.');
-    if ($property_added) {
-      $this->assertInstanceOf('Drupal\Core\TypedData\DataDefinitionInterface', $properties['search_api_aggregation_2'], 'The "search_api_aggregation_2" property contains a valid data definition.');
-      if ($properties['search_api_aggregation_2'] instanceof DataDefinitionInterface) {
-        $this->assertEquals('integer', $properties['search_api_aggregation_2']->getDataType(), 'Correct data type set in the data definition.');
-        $this->assertEquals('Field 2', $properties['search_api_aggregation_2']->getLabel(), 'Correct label set in the data definition.');
-        $description = $translation->translate('A @type aggregation of the following fields: @fields.', array('@type' => $translation->translate('Maximum'), '@fields' => 'datasource » foo:bar'));;
-        $this->assertEquals($description, $properties['search_api_aggregation_2']->getDescription(), 'Correct description set in the data definition.');
-      }
-    }
-
-    // Test whether the properties of specific datasources stay untouched.
-    $properties = array();
-    /** @var \Drupal\search_api\Datasource\DatasourceInterface $datasource */
-    $datasource = $this->getMock('Drupal\search_api\Datasource\DatasourceInterface');
-    $this->processor->alterPropertyDefinitions($properties, $datasource);
-    $this->assertEmpty($properties, 'Datasource-specific properties did not get changed.');
-  }
-
-}
diff --git a/tests/src/Unit/Plugin/Processor/AggregatedFieldsTest.php b/tests/src/Unit/Plugin/Processor/AggregatedFieldsTest.php
new file mode 100644
index 0000000..0d84adc
--- /dev/null
+++ b/tests/src/Unit/Plugin/Processor/AggregatedFieldsTest.php
@@ -0,0 +1,242 @@
+<?php
+
+namespace Drupal\Tests\search_api\Unit\Plugin\Processor;
+
+use Drupal\search_api\Entity\Index;
+use Drupal\search_api\Plugin\search_api\processor\AggregatedFields;
+use Drupal\search_api\Utility;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * Tests the "Aggregated fields" processor.
+ *
+ * @group search_api
+ *
+ * @see \Drupal\search_api\Plugin\search_api\processor\AggregatedField
+ */
+class AggregatedFieldsTest extends UnitTestCase {
+
+  use TestItemsTrait;
+
+  /**
+   * The processor to be tested.
+   *
+   * @var \Drupal\search_api\Plugin\search_api\processor\AggregatedFields
+   */
+  protected $processor;
+
+  /**
+   * A search index mock for the tests.
+   *
+   * @var \Drupal\search_api\IndexInterface|\PHPUnit_Framework_MockObject_MockObject
+   */
+  protected $index;
+
+  /**
+   * The field ID used in this test.
+   *
+   * @var string
+   */
+  protected $fieldId = 'aggregated_field';
+
+  /**
+   * Creates a new processor object for use in the tests.
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    $this->index = new Index(array(
+      'datasourceInstances' => array(
+        'entity:test1' => (object) array(),
+      ),
+      'processorInstances' => array(),
+      'field_settings' => array(
+        'foo' => array(
+          'type' => 'string',
+          'datasource_id' => 'entity:test1',
+          'property_path' => 'foo',
+        ),
+        'bar' => array(
+          'type' => 'string',
+          'datasource_id' => 'entity:test1',
+          'property_path' => 'foo:bar',
+        ),
+        'bla' => array(
+          'type' => 'string',
+          'datasource_id' => 'entity:test2',
+          'property_path' => 'foobaz:bla',
+        ),
+        'aggregated_field' => array(
+          'type' => 'string',
+          'property_path' => 'aggregated_field',
+        ),
+      ),
+      'properties' => array(
+        NULL => array(),
+        'entity:test1' => array(),
+        'entity:test2' => array(),
+      ),
+    ), 'search_api_index');
+    $this->processor = new AggregatedFields(array('index' => $this->index), 'aggregated_field', array());
+    $this->index->addProcessor($this->processor);
+    $this->setUpDataTypePlugin();
+  }
+
+  /**
+   * Tests aggregated fields of the given type.
+   *
+   * @param string $type
+   *   The aggregation type to test.
+   * @param array $expected
+   *   The expected values for the two items.
+   * @param bool $integer
+   *   (optional) TRUE if the items' normal fields should contain integers,
+   *   FALSE otherwise.
+   *
+   * @dataProvider aggregationTestsDataProvider
+   */
+  public function testAggregation($type, $expected, $integer = FALSE) {
+    // Add the field configuration.
+    $configuration = array(
+      'type' => $type,
+      'fields' => array(
+        'entity:test1/foo',
+        'entity:test1/foo:bar',
+        'entity:test2/foobaz:bla',
+      ),
+    );
+    $this->index->getField($this->fieldId)->setConfiguration($configuration);
+
+    if ($integer) {
+      $field_values = array(
+        'foo' => array(2, 4),
+        'bar' => array(16),
+        'bla' => array(7),
+      );
+    }
+    else {
+      $field_values = array(
+        'foo' => array('foo', 'bar'),
+        'bar' => array('baz'),
+        'bla' => array('foobar'),
+      );
+    }
+    $items = array();
+    $i = 0;
+    foreach (array('entity:test1', 'entity:test2') as $datasource_id) {
+      $this->itemIds[$i++] = $item_id = Utility::createCombinedId($datasource_id, '1:en');
+      $item = Utility::createItem($this->index, $item_id);
+      foreach ($this->index->getFields() as $field_id => $field) {
+        $field = clone $field;
+        if (!empty($field_values[$field_id])) {
+          $field->setValues($field_values[$field_id]);
+        }
+        $item->setField($field_id, $field);
+      }
+      $item->setFieldsExtracted(TRUE);
+      $items[$item_id] = $item;
+    }
+
+    // Add the processor's field values to the items.
+    foreach ($items as $item) {
+      $this->processor->addFieldValues($item);
+    }
+
+    $this->assertEquals($expected[0], $items[$this->itemIds[0]]->getField($this->fieldId)->getValues(), 'Correct aggregation for item 1.');
+    $this->assertEquals($expected[1], $items[$this->itemIds[1]]->getField($this->fieldId)->getValues(), 'Correct aggregation for item 2.');
+  }
+
+  /**
+   * Provides test data for aggregation tests.
+   *
+   * @return array
+   *   An array containing test data sets, with each being an array of
+   *   arguments to pass to the test method.
+   *
+   * @see static::testAggregation()
+   */
+  public function aggregationTestsDataProvider() {
+    return array(
+      '"Union" aggregation' => array(
+        'union',
+        array(
+          array('foo', 'bar', 'baz'),
+          array('foobar'),
+        ),
+      ),
+      '"Concatenation" aggregation' => array(
+        'concat',
+        array(
+          array("foo\n\nbar\n\nbaz"),
+          array('foobar'),
+        ),
+      ),
+      '"Sum" aggregation' => array(
+        'sum',
+        array(
+          array(22),
+          array(7),
+        ),
+        TRUE,
+      ),
+      '"Count" aggregation' => array(
+        'count',
+        array(
+          array(3),
+          array(1),
+        ),
+      ),
+      '"Maximum" aggregation' => array(
+        'max',
+        array(
+          array(16),
+          array(7),
+        ),
+        TRUE,
+      ),
+      '"Minimum" aggregation' => array(
+        'min',
+        array(
+          array(2),
+          array(7),
+        ),
+        TRUE,
+      ),
+      '"First" aggregation' => array(
+        'first',
+        array(
+          array('foo'),
+          array('foobar'),
+        ),
+      ),
+    );
+  }
+
+  /**
+   * Tests whether the properties are correctly altered.
+   *
+   * @see \Drupal\search_api\Plugin\search_api\processor\AggregatedField::alterPropertyDefinitions()
+   */
+  public function testAlterPropertyDefinitions() {
+    /** @var \Drupal\Core\StringTranslation\TranslationInterface $translation */
+    $translation = $this->getStringTranslationStub();
+    $this->processor->setStringTranslation($translation);
+
+    // Check for added properties when no datasource is given.
+    /** @var \Drupal\search_api\Processor\ProcessorPropertyInterface[] $properties */
+    $properties = $this->processor->getPropertyDefinitions(NULL);
+
+    $this->assertArrayHasKey('aggregated_field', $properties, 'The "aggregated_field" property was added to the properties.');
+    $this->assertInstanceOf('Drupal\search_api\Plugin\search_api\processor\Property\AggregatedFieldProperty', $properties['aggregated_field'], 'The "aggregated_field" property has the correct class.');
+    $this->assertEquals('string', $properties['aggregated_field']->getDataType(), 'Correct data type set in the data definition.');
+    $this->assertEquals($translation->translate('Aggregated field'), $properties['aggregated_field']->getLabel(), 'Correct label set in the data definition.');
+    $expected_description = $translation->translate('An aggregation of multiple other fields.');
+    $this->assertEquals($expected_description, $properties['aggregated_field']->getDescription(), 'Correct description set in the data definition.');
+
+    // Verify that there are no properties if a datasource is given.
+    $datasource = $this->getMock('Drupal\search_api\Datasource\DatasourceInterface');
+    $properties = $this->processor->getPropertyDefinitions($datasource);
+    $this->assertEmpty($properties, 'Datasource-specific properties did not get changed.');
+  }
+
+}
