diff --git a/modules/ctools_inline_block/config/schema/ctools_inline_block.schema.yml b/modules/ctools_inline_block/config/schema/ctools_inline_block.schema.yml
new file mode 100644
index 0000000..1541114
--- /dev/null
+++ b/modules/ctools_inline_block/config/schema/ctools_inline_block.schema.yml
@@ -0,0 +1,10 @@
+block.settings.inline_content:*:
+  type: block_settings
+  label: 'Inline block content'
+  mapping:
+    langcode:
+      type: string
+      label: 'Language code'
+    entity:
+      type: string
+      label: 'Serialized block_content entity'
diff --git a/modules/ctools_inline_block/ctools_inline_block.info.yml b/modules/ctools_inline_block/ctools_inline_block.info.yml
new file mode 100644
index 0000000..5765535
--- /dev/null
+++ b/modules/ctools_inline_block/ctools_inline_block.info.yml
@@ -0,0 +1,9 @@
+name: 'Inline Blocks'
+type: module
+description: 'Exposes the custom block functionality of Drupal core as inline blocks what do not save to the database.'
+package: 'Chaos tool suite'
+version: 3.x
+core: 8.x
+dependencies:
+    - inline_entity_form
+    - block_content
diff --git a/modules/ctools_inline_block/ctools_inline_block.install b/modules/ctools_inline_block/ctools_inline_block.install
new file mode 100644
index 0000000..81c9110
--- /dev/null
+++ b/modules/ctools_inline_block/ctools_inline_block.install
@@ -0,0 +1,30 @@
+<?php
+
+/**
+ * Implements hook_schema().
+ */
+function ctools_inline_block_schema() {
+  return [
+    'inline_block' => [
+      'fields' => [
+        'uuid' => [
+          'type' => 'varchar',
+          'length' => 64,
+          'not null' => TRUE,
+          'description' => 'UUID of the inline block.',
+        ],
+        'loader' => [
+          'type' => 'varchar',
+          'length' => 128,
+          'not null' => TRUE,
+          'description' => 'ID of the service which can load the block.',
+        ],
+        'data' => [
+          'type' => 'text',
+          'description' => 'Additional data needed to load the block.',
+        ],
+      ],
+      'primary key' => ['uuid'],
+    ],
+  ];
+}
diff --git a/modules/ctools_inline_block/ctools_inline_block.module b/modules/ctools_inline_block/ctools_inline_block.module
new file mode 100644
index 0000000..8002ed8
--- /dev/null
+++ b/modules/ctools_inline_block/ctools_inline_block.module
@@ -0,0 +1,105 @@
+<?php
+
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Entity\Display\EntityFormDisplayInterface;
+use Drupal\block\BlockInterface;
+use Drupal\ctools_inline_block\Plugin\Block\InlineBlock;
+
+/**
+ * Implements hook_ENTITY_TYPE_create().
+ */
+function ctools_inline_block_entity_form_display_create(EntityFormDisplayInterface $form_display) {
+  $mask = \Drupal::entityTypeManager()
+    ->getDefinition($form_display->getTargetEntityTypeId())
+    ->get('mask');
+
+  if ($mask) {
+    $components = entity_get_form_display($mask, $form_display->getTargetBundle(), $form_display->getMode())
+      ->getComponents();
+
+    foreach ($components as $id => $component) {
+      $form_display->setComponent($id, $component);
+    }
+  }
+}
+
+/**
+ * Implements hook_entity_bundle_field_info().
+ */
+function ctools_inline_block_entity_bundle_field_info(EntityTypeInterface $entity_type, $bundle) {
+  $result = [];
+
+  $mask = $entity_type->get('mask');
+  if ($mask) {
+    $storages = ctools_inline_block_entity_field_storage_info($entity_type);
+
+    /** @var \Drupal\field\FieldConfigInterface[] $fields */
+    $fields = \Drupal::entityTypeManager()
+      ->getStorage('field_config')
+      ->loadByProperties([
+        'entity_type' => $mask,
+        'bundle' => $bundle,
+      ]);
+
+    foreach ($fields as $field) {
+      $field_name = $field->getName();
+
+      $result[$field_name] = $field->createDuplicate()
+        ->set('entity_type', $mask)
+        ->set('fieldStorage', $storages[$field_name]);
+    }
+  }
+  return $result;
+}
+
+/**
+ * Implements hook_entity_field_storage_info().
+ */
+function ctools_inline_block_entity_field_storage_info(EntityTypeInterface $entity_type) {
+  $result = [];
+
+  $mask = $entity_type->get('mask');
+  if ($mask) {
+    /** @var \Drupal\field\FieldStorageConfigInterface[] $fields */
+    $fields = \Drupal::entityTypeManager()
+      ->getStorage('field_storage_config')
+      ->loadByProperties([
+        'entity_type' => $mask,
+      ]);
+
+    foreach ($fields as $field) {
+      $result[$field->getName()] = $field->createDuplicate()
+        ->set('entity_type', $mask);
+    }
+  }
+  return $result;
+}
+
+/**
+ * Implements hook_ENTITY_TYPE_insert().
+ */
+function ctools_inline_block_block_insert(BlockInterface $block) {
+  $plugin = $block->getPlugin();
+
+  if ($plugin instanceof InlineBlock) {
+    \Drupal::database()
+      ->insert('inline_block')
+      ->fields([
+        'uuid' => $plugin->getEntity()->uuid(),
+        'loader' => 'ctools.block_loader',
+        'data' => $block->id(),
+      ])
+      ->execute();
+  }
+}
+
+/**
+ * Implements hook_ENTITY_TYPE_delete().
+ */
+function ctools_inline_block_block_delete(BlockInterface $block) {
+  $plugin = $block->getPlugin();
+
+  if ($plugin instanceof InlineBlock) {
+    $plugin->getEntity()->delete();
+  }
+}
diff --git a/modules/ctools_inline_block/ctools_inline_block.services.yml b/modules/ctools_inline_block/ctools_inline_block.services.yml
new file mode 100644
index 0000000..3eed444
--- /dev/null
+++ b/modules/ctools_inline_block/ctools_inline_block.services.yml
@@ -0,0 +1,5 @@
+services:
+  ctools.block_loader:
+    class: '\Drupal\ctools_inline_block\BlockLoader'
+    arguments:
+      - '@entity_type.manager'
diff --git a/modules/ctools_inline_block/src/BlockLoader.php b/modules/ctools_inline_block/src/BlockLoader.php
new file mode 100644
index 0000000..40d413b
--- /dev/null
+++ b/modules/ctools_inline_block/src/BlockLoader.php
@@ -0,0 +1,39 @@
+<?php
+
+namespace Drupal\ctools_inline_block;
+
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+
+class BlockLoader implements BlockLoaderInterface {
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * BlockLoader constructor.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager.
+   */
+  public function __construct(EntityTypeManagerInterface $entity_type_manager) {
+    $this->entityTypeManager = $entity_type_manager;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function load($uuid, $data = NULL) {
+    // $data is expected to be the ID of the block config entity.
+    if ($data) {
+      return $this->entityTypeManager->getStorage('block')->load($data)->getPlugin()->getEntity();
+    }
+    else {
+      throw new \InvalidArgumentException('No block entity ID was specified');
+    }
+  }
+
+}
diff --git a/modules/ctools_inline_block/src/BlockLoaderInterface.php b/modules/ctools_inline_block/src/BlockLoaderInterface.php
new file mode 100644
index 0000000..526c064
--- /dev/null
+++ b/modules/ctools_inline_block/src/BlockLoaderInterface.php
@@ -0,0 +1,23 @@
+<?php
+
+namespace Drupal\ctools_inline_block;
+
+/**
+ * Defines an interface for loading inline blocks in a standard way.
+ */
+interface BlockLoaderInterface {
+
+  /**
+   * Loads an inline block by UUID.
+   *
+   * @param string $uuid
+   *   The inline block UUID.
+   * @param mixed $data
+   *   (optional) Additional data needed to load the block.
+   *
+   * @return \Drupal\ctools_inline_block\Entity\BlockContent
+   *   The inline block, or NULL if it could not be loaded.
+   */
+  public function load($uuid, $data = NULL);
+
+}
diff --git a/modules/ctools_inline_block/src/Entity/BlockContent.php b/modules/ctools_inline_block/src/Entity/BlockContent.php
new file mode 100644
index 0000000..851f3a2
--- /dev/null
+++ b/modules/ctools_inline_block/src/Entity/BlockContent.php
@@ -0,0 +1,70 @@
+<?php
+
+namespace Drupal\ctools_inline_block\Entity;
+
+use Drupal\block_content\Entity\BlockContent as CoreBlockContent;
+
+/**
+ * Defines the custom block entity class.
+ *
+ * @ContentEntityType(
+ *   id = "inline_block_content",
+ *   label = @Translation("Custom block"),
+ *   bundle_label = @Translation("Custom block type"),
+ *   handlers = {
+ *     "storage" = "Drupal\ctools_inline_block\Entity\InlineBlockStorage",
+ *     "access" = "Drupal\block_content\BlockContentAccessControlHandler",
+ *     "list_builder" = "Drupal\block_content\BlockContentListBuilder",
+ *     "view_builder" = "Drupal\block_content\BlockContentViewBuilder",
+ *     "views_data" = "Drupal\block_content\BlockContentViewsData",
+ *     "form" = {
+ *       "add" = "Drupal\block_content\BlockContentForm",
+ *       "edit" = "Drupal\block_content\BlockContentForm",
+ *       "delete" = "Drupal\block_content\Form\BlockContentDeleteForm",
+ *       "default" = "Drupal\block_content\BlockContentForm"
+ *     },
+ *     "translation" = "Drupal\block_content\BlockContentTranslationHandler"
+ *   },
+ *   admin_permission = "administer blocks",
+ *   links = {
+ *     "canonical" = "/block/{block_content}",
+ *     "delete-form" = "/block/{block_content}/delete",
+ *     "edit-form" = "/block/{block_content}",
+ *     "collection" = "/admin/structure/block/block-content",
+ *   },
+ *   translatable = TRUE,
+ *   entity_keys = {
+ *     "id" = "id",
+ *     "revision" = "revision_id",
+ *     "bundle" = "type",
+ *     "label" = "info",
+ *     "langcode" = "langcode",
+ *     "uuid" = "uuid"
+ *   },
+ *   bundle_entity_type = "block_content_type",
+ *   render_cache = FALSE,
+ *   mask = "block_content",
+ * )
+ */
+class BlockContent extends CoreBlockContent {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function id() {
+    // What, I hear you asking, is this unholy jiu-jitsu?
+    //
+    // Inline blocks do not save to the database, so a serial integer ID is
+    // never generated. But in order to be loadable, inline blocks must have an
+    // identifier of some kind. Therefore, the UUID is dual-purposed as the
+    // canonical identifier.
+    //
+    // This can't be formally done in the entity type definition because,
+    // between CoreBlockContent and ContentEntityBase's respective
+    // implementations of ::baseFieldDefinitions(), the 'id' field will never
+    // be defined and a fatal error will happen. So this is the easiest way
+    // to ensure that the ID and UUID are identical at all times.
+    return $this->uuid();
+  }
+
+}
diff --git a/modules/ctools_inline_block/src/Entity/InlineBlockStorage.php b/modules/ctools_inline_block/src/Entity/InlineBlockStorage.php
new file mode 100644
index 0000000..eab7b47
--- /dev/null
+++ b/modules/ctools_inline_block/src/Entity/InlineBlockStorage.php
@@ -0,0 +1,144 @@
+<?php
+
+namespace Drupal\ctools_inline_block\Entity;
+
+use Drupal\Core\Entity\ContentEntityInterface;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Entity\Sql\SqlContentEntityStorage;
+use Drupal\Core\Field\FieldStorageDefinitionInterface;
+
+/**
+ * A content entity storage handler that does not save to the database.
+ */
+class InlineBlockStorage extends SqlContentEntityStorage {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getQueryServiceName() {
+    return 'entity.query.null';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function delete(array $entities) {
+    parent::delete($entities);
+
+    $ids = array_keys($entities);
+
+    $this->database
+      ->delete('inline_block')
+      ->condition('uuid', $ids, 'IN')
+      ->execute();
+
+    $this->resetCache($ids);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function cleanIds(array $ids) {
+    return array_map('strval', $ids);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getFromStorage(array $ids = NULL) {
+    $id_key = $this->idKey;
+    $this->idKey = 'uuid';
+    $entities = parent::getFromStorage($ids);
+    $this->idKey = $id_key;
+    return $entities;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function buildQuery($ids, $revision_id = FALSE) {
+    $query = $this->database->select('inline_block', 'ib')->fields('ib');
+
+    if ($ids) {
+      $query->condition('uuid', $ids, 'IN');
+    }
+    return $query;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function mapFromStorageRecords(array $records, $load_from_revision = FALSE) {
+    $mapped = [];
+    foreach ($records as $uuid => $record) {
+      $mapped[$uuid] = \Drupal::service($record->loader)->load($uuid, $record->data);
+    }
+    return $mapped;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function requiresFieldStorageSchemaChanges(FieldStorageDefinitionInterface $storage_definition, FieldStorageDefinitionInterface $original) {
+    return FALSE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function requiresEntityStorageSchemaChanges(EntityTypeInterface $entity_type, EntityTypeInterface $original) {
+    return FALSE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function requiresEntityDataMigration(EntityTypeInterface $entity_type, EntityTypeInterface $original) {
+    return FALSE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function requiresFieldDataMigration(FieldStorageDefinitionInterface $storage_definition, FieldStorageDefinitionInterface $original) {
+    return FALSE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function onEntityTypeCreate(EntityTypeInterface $entity_type) {
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function onEntityTypeUpdate(EntityTypeInterface $entity_type, EntityTypeInterface $original) {
+    $this->entityType = $entity_type;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function onEntityTypeDelete(EntityTypeInterface $entity_type) {
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function doSaveFieldItems(ContentEntityInterface $entity, array $names = []) {
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function saveToSharedTables(ContentEntityInterface $entity, $table_name = NULL, $new_revision = NULL) {
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function saveToDedicatedTables(ContentEntityInterface $entity, $update = TRUE, $names = array()) {
+  }
+
+}
diff --git a/modules/ctools_inline_block/src/Plugin/Block/InlineBlock.php b/modules/ctools_inline_block/src/Plugin/Block/InlineBlock.php
new file mode 100644
index 0000000..3729eef
--- /dev/null
+++ b/modules/ctools_inline_block/src/Plugin/Block/InlineBlock.php
@@ -0,0 +1,75 @@
+<?php
+
+namespace Drupal\ctools_inline_block\Plugin\Block;
+
+use Drupal\block_content\Plugin\Block\BlockContentBlock;
+use Drupal\Core\Form\FormStateInterface;
+
+/**
+ * Defines a generic custom block type.
+ *
+ * @Block(
+ *  id = "inline_content",
+ *  admin_label = @Translation("Inline block"),
+ *  category = @Translation("Inline"),
+ *  deriver = "Drupal\ctools_inline_block\Plugin\Derivative\InlineBlock"
+ * )
+ */
+class InlineBlock extends BlockContentBlock {
+
+  /**
+   * The inline block content entity.
+   *
+   * @var \Drupal\block_content\BlockContentInterface
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getEntity() {
+    if (empty($this->entity) && isset($this->configuration['entity'])) {
+      $this->entity = unserialize($this->configuration['entity']);
+    }
+    return $this->entity;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function blockForm($form, FormStateInterface $form_state) {
+    $options = $this->entityManager->getViewModeOptionsByBundle('block_content', $this->getDerivativeId());
+    $form['view_mode'] = array(
+      '#type' => 'select',
+      '#options' => $options,
+      '#title' => $this->t('View mode'),
+      '#description' => $this->t('Output the block in this view mode.'),
+      '#default_value' => $this->configuration['view_mode'],
+      '#access' => (count($options) > 1),
+    );
+    $form['title']['#description'] = $this->t('The title of the block as shown to the user.');
+    $form['entity'] = [
+      '#type' => 'inline_entity_form',
+      '#entity_type' => 'inline_block_content',
+      '#bundle' => $this->getDerivativeId(),
+      // If the #default_value is NULL, a new entity will be created.
+      '#default_value' => $this->getEntity(),
+    ];
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function blockSubmit($form, FormStateInterface $form_state) {
+    $this->configuration['view_mode'] = $form_state->getValue('view_mode');
+    /** @var \Drupal\block_content\BlockContentInterface $entity */
+    $entity = $form['settings']['entity']['#entity'] ?: $form['entity']['#entity'];
+    $this->configuration['label'] = $entity->label();
+    $this->configuration['langcode'] = $form_state->getValue('langcode');
+
+    $this->entity = $entity;
+    $this->configuration['entity'] = serialize($entity);
+  }
+
+}
diff --git a/modules/ctools_inline_block/src/Plugin/Derivative/InlineBlock.php b/modules/ctools_inline_block/src/Plugin/Derivative/InlineBlock.php
new file mode 100644
index 0000000..7f91cd6
--- /dev/null
+++ b/modules/ctools_inline_block/src/Plugin/Derivative/InlineBlock.php
@@ -0,0 +1,67 @@
+<?php
+
+namespace Drupal\ctools_inline_block\Plugin\Derivative;
+
+use Drupal\Component\Plugin\Derivative\DeriverBase;
+use Drupal\Core\Entity\EntityStorageInterface;
+use Drupal\Core\Plugin\Discovery\ContainerDeriverInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\Core\StringTranslation\TranslationInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Retrieves bundle types for inline blocks.
+ */
+class InlineBlock extends DeriverBase implements ContainerDeriverInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * The custom block type storage.
+   *
+   * @var \Drupal\Core\Entity\EntityStorageInterface
+   */
+  protected $blockContentTypeStorage;
+
+  /**
+   * Constructs a BlockContentType object.
+   *
+   * @param \Drupal\Core\Entity\EntityStorageInterface $block_content_type_storage
+   *   The custom block storage.
+   * @param \Drupal\Core\StringTranslation\TranslationInterface $translation
+   *   The string translation service.
+   */
+  public function __construct(EntityStorageInterface $block_content_type_storage, TranslationInterface $translation) {
+    $this->blockContentTypeStorage = $block_content_type_storage;
+    $this->stringTranslation = $translation;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, $base_plugin_id) {
+    return new static(
+      $container->get('entity.manager')->getStorage('block_content_type'),
+      $container->get('string_translation')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getDerivativeDefinitions($base_plugin_definition) {
+    $block_types = $this->blockContentTypeStorage->loadMultiple();
+    // Reset the discovered definitions.
+    $this->derivatives = [];
+    /** @var $type \Drupal\block_content\Entity\BlockContent */
+    foreach ($block_types as $id => $type) {
+      $this->derivatives[$id] = $base_plugin_definition;
+      $this->derivatives[$id]['admin_label'] = $this->t('Inline @type', ['@type' => $type->label()]);
+      $this->derivatives[$id]['config_dependencies']['content'] = array(
+        $type->getConfigDependencyName()
+      );
+    }
+    return $this->derivatives;
+  }
+
+}
diff --git a/modules/ctools_inline_block/tests/modules/ctools_inline_block_test/config/install/block_content.type.basic.yml b/modules/ctools_inline_block/tests/modules/ctools_inline_block_test/config/install/block_content.type.basic.yml
new file mode 100644
index 0000000..02982e4
--- /dev/null
+++ b/modules/ctools_inline_block/tests/modules/ctools_inline_block_test/config/install/block_content.type.basic.yml
@@ -0,0 +1,5 @@
+id: basic
+label: 'Basic block'
+revision: 0
+description: 'A basic block contains a title and a body.'
+langcode: en
diff --git a/modules/ctools_inline_block/tests/modules/ctools_inline_block_test/config/install/core.entity_form_display.block_content.basic.default.yml b/modules/ctools_inline_block/tests/modules/ctools_inline_block_test/config/install/core.entity_form_display.block_content.basic.default.yml
new file mode 100644
index 0000000..5923ff8
--- /dev/null
+++ b/modules/ctools_inline_block/tests/modules/ctools_inline_block_test/config/install/core.entity_form_display.block_content.basic.default.yml
@@ -0,0 +1,39 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - block_content.type.basic
+    - field.field.block_content.basic.body
+    - field.field.block_content.basic.field_puppies
+    - image.style.thumbnail
+  module:
+    - image
+    - text
+id: block_content.basic.default
+targetEntityType: block_content
+bundle: basic
+mode: default
+content:
+  body:
+    type: text_textarea_with_summary
+    weight: 1
+    settings:
+      rows: 9
+      summary_rows: 3
+      placeholder: ''
+    third_party_settings: {  }
+  field_puppies:
+    type: image_image
+    weight: 2
+    settings:
+      progress_indicator: throbber
+      preview_image_style: thumbnail
+    third_party_settings: {  }
+  info:
+    type: string_textfield
+    weight: 0
+    settings:
+      size: 60
+      placeholder: ''
+    third_party_settings: {  }
+hidden: {  }
diff --git a/modules/ctools_inline_block/tests/modules/ctools_inline_block_test/config/install/core.entity_view_display.block_content.basic.default.yml b/modules/ctools_inline_block/tests/modules/ctools_inline_block_test/config/install/core.entity_view_display.block_content.basic.default.yml
new file mode 100644
index 0000000..ad99a7e
--- /dev/null
+++ b/modules/ctools_inline_block/tests/modules/ctools_inline_block_test/config/install/core.entity_view_display.block_content.basic.default.yml
@@ -0,0 +1,28 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - block_content.type.basic
+    - field.field.block_content.basic.body
+  module:
+    - text
+id: block_content.basic.default
+targetEntityType: block_content
+bundle: basic
+mode: default
+content:
+  body:
+    type: text_default
+    weight: 0
+    label: hidden
+    settings: {  }
+    third_party_settings: {  }
+  field_puppies:
+    type: image
+    weight: 1
+    label: above
+    settings:
+      image_style: ''
+      image_link: ''
+    third_party_settings: {  }
+hidden: {  }
diff --git a/modules/ctools_inline_block/tests/modules/ctools_inline_block_test/config/install/field.field.block_content.basic.body.yml b/modules/ctools_inline_block/tests/modules/ctools_inline_block_test/config/install/field.field.block_content.basic.body.yml
new file mode 100644
index 0000000..89118ef
--- /dev/null
+++ b/modules/ctools_inline_block/tests/modules/ctools_inline_block_test/config/install/field.field.block_content.basic.body.yml
@@ -0,0 +1,21 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - block_content.type.basic
+    - field.storage.block_content.body
+  module:
+    - text
+id: block_content.basic.body
+field_name: body
+entity_type: block_content
+bundle: basic
+label: Body
+description: ''
+required: false
+translatable: true
+default_value: {  }
+default_value_callback: ''
+settings:
+  display_summary: false
+field_type: text_with_summary
diff --git a/modules/ctools_inline_block/tests/modules/ctools_inline_block_test/config/install/field.field.block_content.basic.field_puppies.yml b/modules/ctools_inline_block/tests/modules/ctools_inline_block_test/config/install/field.field.block_content.basic.field_puppies.yml
new file mode 100644
index 0000000..17168ee
--- /dev/null
+++ b/modules/ctools_inline_block/tests/modules/ctools_inline_block_test/config/install/field.field.block_content.basic.field_puppies.yml
@@ -0,0 +1,37 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - block_content.type.basic
+    - field.storage.block_content.field_puppies
+  module:
+    - image
+id: block_content.basic.field_puppies
+field_name: field_puppies
+entity_type: block_content
+bundle: basic
+label: Puppies
+description: 'This is where you should upload pictures of puppies.'
+required: false
+translatable: false
+default_value: {  }
+default_value_callback: ''
+settings:
+  file_directory: '[date:custom:Y]-[date:custom:m]'
+  file_extensions: 'png gif jpg jpeg'
+  max_filesize: ''
+  max_resolution: ''
+  min_resolution: ''
+  alt_field: false
+  alt_field_required: false
+  title_field: false
+  title_field_required: false
+  default_image:
+    uuid: ''
+    alt: ''
+    title: ''
+    width: null
+    height: null
+  handler: 'default:file'
+  handler_settings: {  }
+field_type: image
diff --git a/modules/ctools_inline_block/tests/modules/ctools_inline_block_test/config/install/field.storage.block_content.field_puppies.yml b/modules/ctools_inline_block/tests/modules/ctools_inline_block_test/config/install/field.storage.block_content.field_puppies.yml
new file mode 100644
index 0000000..b5bee7e
--- /dev/null
+++ b/modules/ctools_inline_block/tests/modules/ctools_inline_block_test/config/install/field.storage.block_content.field_puppies.yml
@@ -0,0 +1,29 @@
+langcode: en
+status: true
+dependencies:
+  module:
+    - block_content
+    - file
+    - image
+id: block_content.field_puppies
+field_name: field_puppies
+entity_type: block_content
+type: image
+settings:
+  uri_scheme: public
+  default_image:
+    uuid: ''
+    alt: ''
+    title: ''
+    width: null
+    height: null
+  target_type: file
+  display_field: false
+  display_default: false
+module: image
+locked: false
+cardinality: 1
+translatable: true
+indexes: {  }
+persist_with_no_fields: false
+custom_storage: false
diff --git a/modules/ctools_inline_block/tests/modules/ctools_inline_block_test/ctools_inline_block_test.info.yml b/modules/ctools_inline_block/tests/modules/ctools_inline_block_test/ctools_inline_block_test.info.yml
new file mode 100644
index 0000000..5c5b50b
--- /dev/null
+++ b/modules/ctools_inline_block/tests/modules/ctools_inline_block_test/ctools_inline_block_test.info.yml
@@ -0,0 +1,8 @@
+type: module
+name: CTools Inline Block Test
+description: 'Required for CTools Inline Block simpletests only.'
+core: 8.x
+dependencies:
+  - ctools_inline_block
+  - image
+  - text
diff --git a/modules/ctools_inline_block/tests/modules/ctools_inline_block_test/ctools_inline_block_test.module b/modules/ctools_inline_block/tests/modules/ctools_inline_block_test/ctools_inline_block_test.module
new file mode 100644
index 0000000..6f1a26a
--- /dev/null
+++ b/modules/ctools_inline_block/tests/modules/ctools_inline_block_test/ctools_inline_block_test.module
@@ -0,0 +1,11 @@
+<?php
+
+/**
+ * Implements template_preprocess_block().
+ */
+function ctools_inline_block_test_preprocess_block(array &$variables) {
+  // Disguise inline_content plugin instances as block_content instances.
+  if ($variables['base_plugin_id'] == 'inline_content') {
+    $variables['plugin_id'] = 'block_content:' . unserialize($variables['configuration']['entity'])->uuid();
+  }
+}
diff --git a/modules/ctools_inline_block/tests/src/Functional/RenderTest.php b/modules/ctools_inline_block/tests/src/Functional/RenderTest.php
new file mode 100644
index 0000000..020d381
--- /dev/null
+++ b/modules/ctools_inline_block/tests/src/Functional/RenderTest.php
@@ -0,0 +1,68 @@
+<?php
+
+namespace Drupal\Tests\ctools_inilne_block\Functional;
+
+use Drupal\block_content\Entity\BlockContent;
+use Drupal\Tests\BrowserTestBase;
+
+/**
+ * Tests that the block_content and inline_block plugins render identical HTML.
+ *
+ * @group ctools_inline_block
+ */
+class RenderTest extends BrowserTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'block',
+    'block_content',
+    'ctools_inline_block',
+    'ctools_inline_block_test',
+    'field',
+    'field_ui',
+    'file',
+    'filter',
+    'image',
+    'text',
+    'user',
+  ];
+
+  public function testInlineContentMarkup() {
+    $assert = $this->assertSession();
+
+    $block_content = BlockContent::create([
+      'type' => 'basic',
+      'info' => $this->randomMachineName(16),
+      'body' => 'Yeah, I like animals better than people sometimes... Especially dogs.',
+    ]);
+    $block_content->save();
+
+    // Place the block using the normal block_content plugin and render
+    // it out. Nothing special here.
+    $block = $this->placeBlock('block_content:' . $block_content->uuid(), [
+      'region' => 'content',
+    ]);
+    $selector = '#block-' . $block->id();
+
+    $this->drupalGet('/user/login');
+
+    $concrete_output = $assert->elementExists('css', $selector)->getOuterHtml();
+
+    // Use the inline_content plugin to display the same block_content entity,
+    // serialized and stored in the plugin configuration.
+    $settings = $block->get('settings');
+    $settings['entity'] = serialize($block_content);
+    $block
+      ->set('plugin', 'inline_content:basic')
+      ->set('settings', $settings)
+      ->save();
+
+    $this->getSession()->reload();
+    $inline_output = $assert->elementExists('css', $selector)->getOuterHtml();
+
+    $this->assertSame($concrete_output, $inline_output);
+  }
+
+}
diff --git a/modules/ctools_inline_block/tests/src/FunctionalJavascript/InlineBlockFieldTest.php b/modules/ctools_inline_block/tests/src/FunctionalJavascript/InlineBlockFieldTest.php
new file mode 100644
index 0000000..ac2334b
--- /dev/null
+++ b/modules/ctools_inline_block/tests/src/FunctionalJavascript/InlineBlockFieldTest.php
@@ -0,0 +1,76 @@
+<?php
+
+namespace Drupal\Tests\ctools_inline_block\FunctionalJavascript;
+
+use Drupal\block\Entity\Block;
+use Drupal\ctools_inline_block\Entity\BlockContent as InlineBlockContent;
+use Drupal\file\Entity\File;
+use Drupal\FunctionalJavascriptTests\JavascriptTestBase;
+
+/**
+ * @group ctools_inline_block
+ */
+class InlineBlockFieldTest extends JavascriptTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'block',
+    'block_content',
+    'ctools_inline_block',
+    'ctools_inline_block_test',
+    'field',
+    'field_ui',
+    'file',
+    'filter',
+    'image',
+    'text',
+    'user',
+  ];
+
+  /**
+   * Tests creating, editing and deleting an inline block with an image field.
+   */
+  public function testImageField() {
+    $account = $this->createUser(['administer blocks']);
+    $this->drupalLogin($account);
+    $this->drupalGet('/admin/structure/block/add/inline_content:basic/classy');
+
+    $page = $this->getSession()->getPage();
+
+    $image = \Drupal::moduleHandler()->getModule('image')->getPath() . '/sample.png';
+    $page->attachFileToField('Puppies', \Drupal::root() . '/' . $image);
+
+    $block_id = strtolower($this->randomMachineName(16));
+
+    // Wait for the file element to do its AJAX business.
+    $result = $this->getSession()->wait(10000, '(typeof(jQuery)=="undefined" || (0 === jQuery.active && 0 === jQuery(\':animated\').length))');
+    if (!$result) {
+      $this->fail('Timed out waiting for AJAX upload to complete.');
+    }
+    $page->fillField('Block description', $this->randomMachineName());
+    $page->fillField('id', $block_id);
+    $page->pressButton('Save block');
+
+    $block = Block::load($block_id);
+    $block_content = $block->getPlugin()->getEntity();
+    $this->assertInstanceOf(InlineBlockContent::class, $block_content);
+    $this->assertFalse($block_content->field_puppies->isEmpty());
+
+    /** @var \Drupal\file\FileInterface $image */
+    $image = $block_content->field_puppies->entity;
+    /** @var \Drupal\file\FileUsage\FileUsageInterface $file_usage */
+    $file_usage = \Drupal::service('file.usage');
+    $this->assertTrue($image->isPermanent());
+    $this->assertNotEmpty($file_usage->listUsage($image));
+
+    // Deleting the block should delete the inline block, which in turn should
+    // cause the image to be marked as temporary since nothing will be using it.
+    $block->delete();
+    $this->assertEmpty($file_usage->listUsage($image));
+    $image = \Drupal::entityTypeManager()->getStorage('file')->loadUnchanged($image->id());
+    $this->assertTrue($image->isTemporary());
+  }
+
+}
diff --git a/modules/ctools_inline_block/tests/src/Kernel/StorageTest.php b/modules/ctools_inline_block/tests/src/Kernel/StorageTest.php
new file mode 100644
index 0000000..3dd0e1d
--- /dev/null
+++ b/modules/ctools_inline_block/tests/src/Kernel/StorageTest.php
@@ -0,0 +1,277 @@
+<?php
+
+namespace Drupal\Tests\ctools_inline_block\Kernel;
+
+use Drupal\block\Entity\Block;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\ctools_inline_block\Entity\BlockContent as InlineBlockContent;
+
+/**
+ * Tests the storage layer of inline blocks.
+ *
+ * @group ctools_inline_block
+ */
+class StorageTest extends KernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'block',
+    'block_content',
+    'ctools_inline_block',
+    'ctools_inline_block_test',
+    'field',
+    'field_ui',
+    'file',
+    'filter',
+    'image',
+    'text',
+    'user',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    $this->installConfig('block_content');
+    $this->installConfig('ctools_inline_block_test');
+
+    $this->installEntitySchema('block_content');
+    $this->installEntitySchema('file');
+
+    $this->installSchema('ctools_inline_block', ['inline_block']);
+  }
+
+  /**
+   * Tests that inline blocks go to /dev/null if saved on their own.
+   */
+  public function testLoadWithoutBlock() {
+    $block_content = InlineBlockContent::create([
+      'type' => 'basic',
+      'info' => $this->randomMachineName(),
+      'body' => 'Pasta ipsum dolor sit amet tripoline farfalle croxetti mezzelune spirali manicotti ditalini spaghetti calamarata ricciolini ciriole spirali cencioni.',
+    ]);
+    $block_content->save();
+
+    $id = $block_content->id();
+    $this->assertNotEmpty($id);
+    $this->assertEquals($id, $block_content->uuid());
+
+    $this->assertNull(InlineBlockContent::load($id));
+    $this->assertEmpty(InlineBlockContent::loadMultiple([$id]));
+    $this->assertEmpty(InlineBlockContent::loadMultiple());
+  }
+
+  /**
+   * Tests loading inline blocks by UUID.
+   *
+   * @depends testLoadWithoutBlock
+   */
+  public function testLoadWithBlock() {
+    $block_content = InlineBlockContent::create([
+      'type' => 'basic',
+      'info' => $this->randomMachineName(),
+      'body' => 'Pasta ipsum dolor sit amet tripoline farfalle croxetti mezzelune spirali manicotti ditalini spaghetti calamarata ricciolini ciriole spirali cencioni.',
+    ]);
+    $block_content->save();
+    $id = $block_content->id();
+
+    $block = Block::create([
+      'id' => $this->randomMachineName(),
+      'plugin' => 'inline_content:basic',
+      'settings' => [
+        'entity' => serialize($block_content),
+      ],
+    ]);
+    $block->save();
+
+    $this->assertInstanceOf(InlineBlockContent::class, InlineBlockContent::load($id));
+
+    $loaded = InlineBlockContent::loadMultiple([$id]);
+    $this->assertInstanceOf(InlineBlockContent::class, $loaded[$id]);
+  }
+
+  /**
+   * Tests that inline blocks can be created like normal custom blocks.
+   */
+  public function testCreate() {
+    $body = 'Pasta ipsum dolor sit amet quadrefiore vermicelli rigatoncini fettuccine timpano. Mezzelune penne trofie penne lisce trenne trennette timpano corzetti tagliatelle calamaretti capunti fedelini corzetti farfalline.';
+
+    $block_content = InlineBlockContent::create([
+      'type' => 'basic',
+      'body' => $body,
+    ]);
+
+    // Assert that it's an inline block masquerading as a normal block_content.
+    $this->assertInstanceOf(InlineBlockContent::class, $block_content);
+    $this->assertSame('block_content', $block_content->getEntityTypeId());
+    // Like any other entity, inline blocks should be new if unsaved.
+    $this->assertTrue($block_content->isNew());
+
+    // Assert that it has the same fields.
+    $this->assertTrue($block_content->hasField('body'));
+    $this->assertEquals($body, $block_content->body->value);
+    $this->assertTrue($block_content->hasField('field_puppies'));
+    $this->assertTrue($block_content->get('field_puppies')->isEmpty());
+  }
+
+  /**
+   * Tests that inline blocks don't put anything in the database when saved.
+   *
+   * @depends testCreate
+   */
+  public function testSave() {
+    $block_content = InlineBlockContent::create([
+      'type' => 'test_type',
+      'info' => $this->randomMachineName(),
+      'body' => 'Creste di galli spaghettoni trennette pennette trennette penne zita linguettine orecchiette pappardelle gramigna casarecce.',
+    ]);
+    $block_content->save();
+
+    // Inline blocks use their UUID as their ID.
+    $this->assertEquals($block_content->id(), $block_content->uuid());
+    // Like any other entity, inline blocks with an ID should not be new.
+    $this->assertFalse($block_content->isNew());
+
+    // Ensure that no concrete block_content entities were created.
+    $count = \Drupal::entityQuery('block_content')->count()->execute();
+    $this->assertNumeric($count);
+    $this->assertEmpty($count);
+
+    // Nothing should be written to the field tables.
+    $this->assertTableIsEmpty('block_content__body');
+    $this->assertTableIsEmpty('block_content_revision__body');
+
+    // Inline blocks are invisible to entity queries.
+    $count = \Drupal::entityQuery('inline_block_content')->count()->execute();
+    $this->assertNumeric($count);
+    $this->assertEmpty($count);
+
+    // Nothing should be in the tracking table if saved directly.
+    $this->assertTableIsEmpty('inline_block');
+  }
+
+  /**
+   * Tests deleting an inline block directly.
+   *
+   * @depends testLoadWithBlock
+   */
+  public function testDirectDelete() {
+    $block_content = InlineBlockContent::create([
+      'type' => 'basic',
+      'info' => $this->randomMachineName(),
+      'body' => 'Pasta ipsum dolor sit amet tripoline farfalle croxetti mezzelune spirali manicotti ditalini spaghetti calamarata ricciolini ciriole spirali cencioni.',
+    ]);
+    $block_content->save();
+
+    $block = Block::create([
+      'id' => $this->randomMachineName(),
+      'plugin' => 'inline_content:basic',
+      'settings' => [
+        'entity' => serialize($block_content),
+      ],
+    ]);
+    $block->save();
+
+    // Because the inline block is associated with a real block, there should
+    // be an entry in the tracking table.
+    $this->assertTableIsNotEmpty('inline_block');
+
+    // Deleting the inline block should clear it from the tracking table...
+    $block_content->delete();
+    $this->assertTableIsEmpty('inline_block');
+
+    // ...which means it should no longer be loadable.
+    $id = $block_content->id();
+    $this->assertNull(InlineBlockContent::load($id));
+  }
+
+  /**
+   * Tests deleting a block that contains an inline block.
+   *
+   * @depends testDirectDelete
+   */
+  public function testDeleteBlock() {
+    $block_content = InlineBlockContent::create([
+      'type' => 'basic',
+      'info' => $this->randomMachineName(),
+      'body' => 'Pasta ipsum dolor sit amet tripoline farfalle croxetti mezzelune spirali manicotti ditalini spaghetti calamarata ricciolini ciriole spirali cencioni.',
+    ]);
+    $block_content->save();
+
+    $block = Block::create([
+      'id' => $this->randomMachineName(),
+      'plugin' => 'inline_content:basic',
+      'settings' => [
+        'entity' => serialize($block_content),
+      ],
+    ]);
+    $block->save();
+
+    // Because the inline block is associated with a real block, there should
+    // be an entry in the tracking table.
+    $this->assertTableIsNotEmpty('inline_block');
+
+    // Deleting the block should clear the inline block from the tracking table.
+    $block->delete();
+    $this->assertTableIsEmpty('inline_block');
+
+    // ...which means it should no longer be loadable.
+    $id = $block_content->id();
+    $this->assertNull(InlineBlockContent::load($id));
+  }
+
+  /**
+   * Asserts that a value is numeric.
+   *
+   * @param mixed $value
+   *   The value to check.
+   */
+  protected function assertNumeric($value) {
+    $this->assertTrue(is_numeric($value));
+  }
+
+  /**
+   * Counts the rows in a database table.
+   *
+   * @param string $table
+   *   The table to count.
+   *
+   * @return int
+   *   The number of rows in the table.
+   */
+  protected function countTable($table) {
+    $count = \Drupal::database()
+      ->select($table)
+      ->countQuery()
+      ->execute()
+      ->fetchField();
+
+    $this->assertNumeric($count);
+    return (int) $count;
+  }
+
+  /**
+   * Asserts that a database table is empty.
+   *
+   * @param string $table
+   *   The table to check.
+   */
+  protected function assertTableIsEmpty($table) {
+    $this->assertEmpty($this->countTable($table));
+  }
+
+  /**
+   * Asserts that a database table is not empty.
+   *
+   * @param string $table
+   *   The table to check.
+   */
+  protected function assertTableIsNotEmpty($table) {
+    $this->assertNotEmpty($this->countTable($table));
+  }
+
+}
